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
+98 -128
View File
@@ -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>
+4 -7
View File
@@ -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();