Fix Chrome audio reliability and prevent double audio

This commit is contained in:
Jannis Braun
2026-02-19 20:49:59 +01:00
parent 5ba64d2aea
commit 8afd960a43
2 changed files with 104 additions and 137 deletions
+100 -130
View File
@@ -20,84 +20,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
const perUserVolume = participantVolumes.get(participant.userId) ?? 100; const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal; 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%) // Web Audio for volume boost (> 100%)
const gainNodeRef = useRef<GainNode | null>(null); const gainNodeRef = useRef<GainNode | null>(null);
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | 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 // Determine active video track
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null; const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : 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 hasVideo = activeVideoTrack !== null;
const isScreenShare = liveScreen !== null; const isScreenShare = liveScreen !== null;
// Listen for track 'ended' events // 1. STANDARD AUDIO PLAYBACK (Reliability Layer)
useEffect(() => { useEffect(() => {
const tracks = [participant.videoTrack, participant.screenTrack].filter( const audioEl = audioRef.current;
(t): t is MediaStreamTrack => t !== null, if (isLocal || !audioEl || !participant.audioTrack) return;
);
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]);
// 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(() => { useEffect(() => {
const videoEl = videoRef.current; const videoEl = videoRef.current;
if (!videoEl) return; if (!videoEl) return;
@@ -127,30 +116,32 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
} }
}, [activeVideoTrack]); }, [activeVideoTrack]);
// Attach audio track // Cleanup Listeners
useEffect(() => { useEffect(() => {
const audioEl = audioRef.current; const tracks = [participant.videoTrack, participant.screenTrack].filter((t): t is MediaStreamTrack => t !== null);
if (!audioEl || !participant.audioTrack) return; if (tracks.length === 0) return;
audioEl.srcObject = new MediaStream([participant.audioTrack]); const onEnded = () => forceUpdate((n) => n + 1);
// Note: play() and muted state are handled by the volume effect above tracks.forEach((t) => t.addEventListener('ended', onEnded));
}, [participant.audioTrack]); return () => tracks.forEach((t) => t.removeEventListener('ended', onEnded));
}, [participant.videoTrack, participant.screenTrack]);
// Interaction (Resume Context)
const handleInteraction = useCallback(() => {
const ctx = getSharedAudioCtx();
if (ctx && ctx.state === 'suspended') {
ctx.resume().catch(console.error);
}
}, []);
// Volume context menu
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
const handleContextMenu = useCallback( const handleContextMenu = useCallback((e: React.MouseEvent) => {
(e: React.MouseEvent) => { if (isLocal) return;
if (isLocal) return; e.preventDefault();
e.preventDefault(); handleInteraction();
const ctx = getSharedAudioCtx(); setVolumeMenu({ x: e.clientX, y: e.clientY });
if (ctx && ctx.state === 'suspended') { }, [isLocal, handleInteraction]);
ctx.resume();
}
setVolumeMenu({ x: e.clientX, y: e.clientY });
},
[isLocal],
);
useEffect(() => { useEffect(() => {
if (!volumeMenu) return; if (!volumeMenu) return;
@@ -159,13 +150,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
return () => window.removeEventListener('click', close); return () => window.removeEventListener('click', close);
}, [volumeMenu]); }, [volumeMenu]);
const handleInteraction = useCallback(() => {
const ctx = getSharedAudioCtx();
if (ctx && ctx.state === 'suspended') {
ctx.resume().catch(console.error);
}
}, []);
return ( return (
<div <div
onClick={handleInteraction} onClick={handleInteraction}
@@ -176,7 +160,8 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`} } ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
{!isLocal && <audio ref={audioRef} autoPlay />} {/* Audio Element: Primary playback device */}
{!isLocal && <audio ref={audioRef} autoPlay playsInline />}
{hasVideo ? ( {hasVideo ? (
<video <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="w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]">
<div className="relative"> <div className="relative">
<Avatar <Avatar src={null} name={participant.username} size={large ? 100 : 64} />
src={null}
name={participant.username}
size={large ? 100 : 64}
/>
{participant.isSpeaking && ( {participant.isSpeaking && (
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" /> <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="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 justify-between">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span <span className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}>
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{participant.username} {participant.username}
</span> </span>
{isLocal && ( {isLocal && <span className="text-[10px] text-white/40 font-medium">(you)</span>}
<span className="text-[10px] text-white/40 font-medium">(you)</span>
)}
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{participant.isMuted && ( {participant.isMuted && (
@@ -236,13 +213,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
</svg> </svg>
</div> </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> </div>
</div> </div>
+4 -7
View File
@@ -14,7 +14,7 @@ import { api } from '../api/client';
import { useVoiceStore } from '../stores/voiceStore'; import { useVoiceStore } from '../stores/voiceStore';
/** /**
* OPENCORD NATIVE OVERDRIVE PIPELINE v30 * OPENCORD NATIVE OVERDRIVE PIPELINE v32
*/ */
const QUALITY_MAP: Record<string, VideoPreset> = { const QUALITY_MAP: Record<string, VideoPreset> = {
@@ -191,7 +191,7 @@ export function useLiveKit() {
const setupLocalGainPipeline = useCallback(async (room: Room, audioTrack: LocalAudioTrack) => { const setupLocalGainPipeline = useCallback(async (room: Room, audioTrack: LocalAudioTrack) => {
try { try {
const ctx = getSharedAudioCtx(); const ctx = getSharedAudioCtx();
if (!ctx) return; if (!ctx || !audioTrack.mediaStreamTrack) return;
if (!localGainNodeRef.current) { if (!localGainNodeRef.current) {
localGainNodeRef.current = ctx.createGain(); localGainNodeRef.current = ctx.createGain();
@@ -203,7 +203,7 @@ export function useLiveKit() {
localSourceRef.current = ctx.createMediaStreamSource(new MediaStream([audioTrack.mediaStreamTrack])); localSourceRef.current = ctx.createMediaStreamSource(new MediaStream([audioTrack.mediaStreamTrack]));
localSourceRef.current.connect(localGainNodeRef.current!); localSourceRef.current.connect(localGainNodeRef.current!);
// Initialize gain from store // Set gain from store
localGainNodeRef.current!.gain.value = useVoiceStore.getState().inputVolume / 100; localGainNodeRef.current!.gain.value = useVoiceStore.getState().inputVolume / 100;
const processedTrack = localDestRef.current!.stream.getAudioTracks()[0]; const processedTrack = localDestRef.current!.stream.getAudioTracks()[0];
@@ -275,10 +275,7 @@ export function useLiveKit() {
await newRoom.connect(url, token); await newRoom.connect(url, token);
if (gen !== _connectGeneration) { newRoom.disconnect(); return; } if (gen !== _connectGeneration) { newRoom.disconnect(); return; }
_activeRoom = newRoom; _activeRoom = newRoom; connectedChannelRef.current = channelId; setRoom(newRoom); setIsConnected(true);
connectedChannelRef.current = channelId;
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true); useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants(); updateParticipants();