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 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]);
|
||||||
|
|
||||||
// Volume context menu
|
// Interaction (Resume Context)
|
||||||
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
|
const handleInteraction = useCallback(() => {
|
||||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
|
||||||
|
|
||||||
const handleContextMenu = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (isLocal) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const ctx = getSharedAudioCtx();
|
const ctx = getSharedAudioCtx();
|
||||||
if (ctx && ctx.state === 'suspended') {
|
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 });
|
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||||
},
|
}, [isLocal, handleInteraction]);
|
||||||
[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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user