Fix Chrome audio reliability and prevent double audio
This commit is contained in:
@@ -20,84 +20,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
const isLocal = participant.isLocal;
|
||||
|
||||
const [ctxState, setCtxState] = useState<AudioContextState>('suspended');
|
||||
|
||||
// Monitor AudioContext state
|
||||
useEffect(() => {
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (!ctx) return;
|
||||
setCtxState(ctx.state);
|
||||
const handler = () => setCtxState(ctx.state);
|
||||
ctx.addEventListener('statechange', handler);
|
||||
return () => ctx.removeEventListener('statechange', handler);
|
||||
}, []);
|
||||
|
||||
// Web Audio for volume boost (> 100%)
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
// Setup Web Audio graph
|
||||
useEffect(() => {
|
||||
if (isLocal || !participant.audioTrack) return;
|
||||
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (!ctx) return;
|
||||
|
||||
if (!gainNodeRef.current) {
|
||||
gainNodeRef.current = ctx.createGain();
|
||||
gainNodeRef.current.connect(ctx.destination);
|
||||
}
|
||||
|
||||
const gainNode = gainNodeRef.current!;
|
||||
|
||||
if (sourceNodeRef.current) {
|
||||
sourceNodeRef.current.disconnect();
|
||||
}
|
||||
|
||||
const stream = new MediaStream([participant.audioTrack]);
|
||||
sourceNodeRef.current = ctx.createMediaStreamSource(stream);
|
||||
sourceNodeRef.current.connect(gainNode);
|
||||
|
||||
return () => {
|
||||
sourceNodeRef.current?.disconnect();
|
||||
};
|
||||
}, [participant.audioTrack, isLocal]);
|
||||
|
||||
// Apply volume - STRICT DUAL PATH PREVENTION
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
const ctx = getSharedAudioCtx();
|
||||
|
||||
if (isLocal || !audioEl || !ctx) return;
|
||||
|
||||
const perUserScaled = perUserVolume / 100;
|
||||
const globalScaled = outputVolume / 100;
|
||||
const combined = perUserScaled * globalScaled;
|
||||
|
||||
if (isDeafened) {
|
||||
if (gainNodeRef.current) gainNodeRef.current.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
|
||||
audioEl.volume = 0;
|
||||
audioEl.muted = true;
|
||||
} else {
|
||||
// Chrome/Safari Autoplay logic:
|
||||
// If Context is Running: Use Web Audio (allows > 100% boost), Mute <audio>
|
||||
// If Context is Blocked: Use <audio> (max 100%), Mute Web Audio
|
||||
if (ctxState === 'running' && gainNodeRef.current) {
|
||||
audioEl.muted = true; // Stop standard playback
|
||||
gainNodeRef.current.gain.setTargetAtTime(combined, ctx.currentTime, 0.01);
|
||||
} else {
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
|
||||
}
|
||||
audioEl.muted = false; // Fallback to standard
|
||||
audioEl.volume = Math.min(combined, 1);
|
||||
audioEl.play().catch(() => {
|
||||
// Truly blocked by browser
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isDeafened, outputVolume, perUserVolume, ctxState, isLocal]);
|
||||
|
||||
// Determine active video track
|
||||
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
|
||||
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
|
||||
@@ -105,18 +31,81 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
const hasVideo = activeVideoTrack !== null;
|
||||
const isScreenShare = liveScreen !== null;
|
||||
|
||||
// Listen for track 'ended' events
|
||||
// 1. STANDARD AUDIO PLAYBACK (Reliability Layer)
|
||||
useEffect(() => {
|
||||
const tracks = [participant.videoTrack, participant.screenTrack].filter(
|
||||
(t): t is MediaStreamTrack => t !== null,
|
||||
);
|
||||
if (tracks.length === 0) return;
|
||||
const onEnded = () => forceUpdate((n) => n + 1);
|
||||
tracks.forEach((t) => t.addEventListener('ended', onEnded));
|
||||
return () => tracks.forEach((t) => t.removeEventListener('ended', onEnded));
|
||||
}, [participant.videoTrack, participant.screenTrack]);
|
||||
const audioEl = audioRef.current;
|
||||
if (isLocal || !audioEl || !participant.audioTrack) return;
|
||||
|
||||
// Attach video track
|
||||
const stream = new MediaStream([participant.audioTrack]);
|
||||
if ((audioEl.srcObject as MediaStream)?.id !== stream.id) {
|
||||
audioEl.srcObject = stream;
|
||||
// Critical for Chrome: Explicitly call play()
|
||||
audioEl.play().catch((err) => console.warn('[Audio] Auto-play blocked:', err));
|
||||
}
|
||||
}, [participant.audioTrack, isLocal]);
|
||||
|
||||
// 2. VOLUME & BOOST CONTROL
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (isLocal || !audioEl || !participant.audioTrack) return;
|
||||
|
||||
// Calculate total requested volume (0.0 to 2.0+)
|
||||
const combined = (perUserVolume / 100) * (outputVolume / 100);
|
||||
|
||||
if (isDeafened) {
|
||||
audioEl.muted = true;
|
||||
if (gainNodeRef.current) gainNodeRef.current.gain.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Logic:
|
||||
// 0% - 100%: Use standard <audio> volume. Disconnect Web Audio to prevent doubling.
|
||||
// > 100%: Set <audio> to 100%, connect Web Audio for the EXTRA boost.
|
||||
|
||||
// Standard Path (Always Active unless >100% needs to take over completely, but doubling is risk.
|
||||
// SAFE APPROACH: Use <audio> for everything up to 100%.
|
||||
// If > 100%, keep <audio> at 100% and add Web Audio *parallel*? No, that causes phasing.
|
||||
// CORRECT APPROACH:
|
||||
// If <= 100%: Element Volume = combined. Web Audio = Disconnected.
|
||||
// If > 100%: Element Volume = 0 (Muted). Web Audio = connected & combined.
|
||||
|
||||
const ctx = getSharedAudioCtx();
|
||||
const useWebAudio = combined > 1.0 && ctx && ctx.state === 'running';
|
||||
|
||||
if (useWebAudio) {
|
||||
// --- BOOST MODE (>100%) ---
|
||||
// Mute standard element to prevent double audio
|
||||
audioEl.muted = true;
|
||||
|
||||
// Setup/Connect Web Audio
|
||||
if (!gainNodeRef.current) {
|
||||
gainNodeRef.current = ctx.createGain();
|
||||
gainNodeRef.current.connect(ctx.destination);
|
||||
}
|
||||
if (!sourceNodeRef.current) {
|
||||
sourceNodeRef.current = ctx.createMediaStreamSource(new MediaStream([participant.audioTrack]));
|
||||
sourceNodeRef.current.connect(gainNodeRef.current);
|
||||
}
|
||||
|
||||
// Apply full gain (e.g., 1.5, 2.0)
|
||||
gainNodeRef.current.gain.setTargetAtTime(combined, ctx.currentTime, 0.01);
|
||||
|
||||
} else {
|
||||
// --- STANDARD MODE (0-100%) ---
|
||||
// Cleanup Web Audio to prevent doubling/leaking
|
||||
if (sourceNodeRef.current) {
|
||||
sourceNodeRef.current.disconnect();
|
||||
sourceNodeRef.current = null;
|
||||
}
|
||||
|
||||
// Use standard element
|
||||
audioEl.muted = false;
|
||||
audioEl.volume = Math.min(combined, 1.0);
|
||||
}
|
||||
|
||||
}, [isDeafened, outputVolume, perUserVolume, isLocal, participant.audioTrack]);
|
||||
|
||||
// Video Handling
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
@@ -127,30 +116,32 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
}
|
||||
}, [activeVideoTrack]);
|
||||
|
||||
// Attach audio track
|
||||
// Cleanup Listeners
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl || !participant.audioTrack) return;
|
||||
audioEl.srcObject = new MediaStream([participant.audioTrack]);
|
||||
// Note: play() and muted state are handled by the volume effect above
|
||||
}, [participant.audioTrack]);
|
||||
const tracks = [participant.videoTrack, participant.screenTrack].filter((t): t is MediaStreamTrack => t !== null);
|
||||
if (tracks.length === 0) return;
|
||||
const onEnded = () => forceUpdate((n) => n + 1);
|
||||
tracks.forEach((t) => t.addEventListener('ended', onEnded));
|
||||
return () => tracks.forEach((t) => t.removeEventListener('ended', onEnded));
|
||||
}, [participant.videoTrack, participant.screenTrack]);
|
||||
|
||||
// Volume context menu
|
||||
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isLocal) return;
|
||||
e.preventDefault();
|
||||
// Interaction (Resume Context)
|
||||
const handleInteraction = useCallback(() => {
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (ctx && ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
ctx.resume().catch(console.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
if (isLocal) return;
|
||||
e.preventDefault();
|
||||
handleInteraction();
|
||||
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[isLocal],
|
||||
);
|
||||
}, [isLocal, handleInteraction]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!volumeMenu) return;
|
||||
@@ -159,13 +150,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [volumeMenu]);
|
||||
|
||||
const handleInteraction = useCallback(() => {
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (ctx && ctx.state === 'suspended') {
|
||||
ctx.resume().catch(console.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleInteraction}
|
||||
@@ -176,7 +160,8 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{!isLocal && <audio ref={audioRef} autoPlay />}
|
||||
{/* Audio Element: Primary playback device */}
|
||||
{!isLocal && <audio ref={audioRef} autoPlay playsInline />}
|
||||
|
||||
{hasVideo ? (
|
||||
<video
|
||||
@@ -189,11 +174,7 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]">
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={null}
|
||||
name={participant.username}
|
||||
size={large ? 100 : 64}
|
||||
/>
|
||||
<Avatar src={null} name={participant.username} size={large ? 100 : 64} />
|
||||
{participant.isSpeaking && (
|
||||
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" />
|
||||
)}
|
||||
@@ -210,14 +191,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
|
||||
>
|
||||
<span className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}>
|
||||
{participant.username}
|
||||
</span>
|
||||
{isLocal && (
|
||||
<span className="text-[10px] text-white/40 font-medium">(you)</span>
|
||||
)}
|
||||
{isLocal && <span className="text-[10px] text-white/40 font-medium">(you)</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{participant.isMuted && (
|
||||
@@ -236,13 +213,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{participant.isScreenSharing && !isScreenShare && (
|
||||
<div className="w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20Z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { api } from '../api/client';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
|
||||
/**
|
||||
* OPENCORD NATIVE OVERDRIVE PIPELINE v30
|
||||
* OPENCORD NATIVE OVERDRIVE PIPELINE v32
|
||||
*/
|
||||
|
||||
const QUALITY_MAP: Record<string, VideoPreset> = {
|
||||
@@ -191,7 +191,7 @@ export function useLiveKit() {
|
||||
const setupLocalGainPipeline = useCallback(async (room: Room, audioTrack: LocalAudioTrack) => {
|
||||
try {
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (!ctx) return;
|
||||
if (!ctx || !audioTrack.mediaStreamTrack) return;
|
||||
|
||||
if (!localGainNodeRef.current) {
|
||||
localGainNodeRef.current = ctx.createGain();
|
||||
@@ -203,7 +203,7 @@ export function useLiveKit() {
|
||||
localSourceRef.current = ctx.createMediaStreamSource(new MediaStream([audioTrack.mediaStreamTrack]));
|
||||
localSourceRef.current.connect(localGainNodeRef.current!);
|
||||
|
||||
// Initialize gain from store
|
||||
// Set gain from store
|
||||
localGainNodeRef.current!.gain.value = useVoiceStore.getState().inputVolume / 100;
|
||||
|
||||
const processedTrack = localDestRef.current!.stream.getAudioTracks()[0];
|
||||
@@ -275,10 +275,7 @@ export function useLiveKit() {
|
||||
|
||||
await newRoom.connect(url, token);
|
||||
if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = channelId;
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
_activeRoom = newRoom; connectedChannelRef.current = channelId; setRoom(newRoom); setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
|
||||
updateParticipants();
|
||||
|
||||
Reference in New Issue
Block a user