fix: eliminate voice ducking caused by rogue LiveKit audio elements

Add MutationObserver to neutralize LiveKit's re-attached <audio> elements,
mark our keep-alive elements with data-opencord, detach tracks on unsubscribe,
remove dangerous blanket .play(), and soften compressor to transparent limiter.
This commit is contained in:
Jannis Braun
2026-02-20 07:03:41 +01:00
parent 6f777f97ce
commit 5c38f076d3
22 changed files with 511 additions and 254 deletions
@@ -3,8 +3,9 @@ import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
/**
* Renders an invisible <audio> element for a single audio track.
* Uses the shared useAudioTrackPlayer hook for the hybrid native/Web Audio pipeline.
* 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, }) {
const globalScale = globalVolume / 100;
@@ -20,15 +21,22 @@ function AudioTrackElement({ track, globalVolume, perSourceVolume, isDeafened, i
volume: finalVolume,
muted: shouldMute,
});
return _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true });
// 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 _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true, "data-opencord": "keepalive" });
}
/**
* Always-mounted component that renders invisible <audio> elements
* 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);
@@ -37,6 +45,7 @@ export function GlobalAudioRenderer() {
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
const streamMutes = useVoiceStore((s) => s.streamMutes);
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
// Determine if someone is currently speaking (for stream attenuation)
@@ -47,6 +56,6 @@ export function GlobalAudioRenderer() {
const micVolume = participantVolumes.get(p.userId) ?? 100;
const streamVol = streamVolumes.get(p.userId) ?? 100;
const isStreamMuted = streamMutes.get(p.userId) ?? false;
return (_jsxs(React.Fragment, { children: [p.audioTrack && (_jsx(AudioTrackElement, { track: p.audioTrack, globalVolume: outputVolume, perSourceVolume: micVolume, isDeafened: isDeafened, isMuted: false, attenuate: false, someoneIsSpeaking: false, attenuationEnabled: false, attenuationStrength: 0 })), p.screenAudioTrack && (_jsx(AudioTrackElement, { track: p.screenAudioTrack, globalVolume: outputVolume, perSourceVolume: streamVol, isDeafened: isDeafened, isMuted: isStreamMuted, attenuate: true, someoneIsSpeaking: someoneIsSpeaking, attenuationEnabled: streamAttenuationEnabled, attenuationStrength: streamAttenuationStrength }))] }, p.identity));
return (_jsxs(React.Fragment, { children: [p.audioTrack && (_jsx(AudioTrackElement, { track: p.audioTrack, globalVolume: outputVolume, perSourceVolume: micVolume, isDeafened: isDeafened, isMuted: false, attenuate: false, someoneIsSpeaking: false, attenuationEnabled: false, attenuationStrength: 0 })), p.screenAudioTrack && watchingStreams.has(p.userId) && (_jsx(AudioTrackElement, { track: p.screenAudioTrack, globalVolume: outputVolume, perSourceVolume: streamVol, isDeafened: isDeafened, isMuted: isStreamMuted, attenuate: true, someoneIsSpeaking: someoneIsSpeaking, attenuationEnabled: streamAttenuationEnabled, attenuationStrength: streamAttenuationStrength }))] }, p.identity));
}) }));
}
@@ -4,8 +4,9 @@ import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
/**
* Renders an invisible <audio> element for a single audio track.
* Uses the shared useAudioTrackPlayer hook for the hybrid native/Web Audio pipeline.
* 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,
@@ -45,16 +46,23 @@ function AudioTrackElement({
muted: shouldMute,
});
return <audio ref={audioRef} autoPlay playsInline />;
// 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-opencord="keepalive" />;
}
/**
* Always-mounted component that renders invisible <audio> elements
* 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);
@@ -63,6 +71,7 @@ export function GlobalAudioRenderer() {
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
const streamMutes = useVoiceStore((s) => s.streamMutes);
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
@@ -96,8 +105,8 @@ export function GlobalAudioRenderer() {
/>
)}
{/* Screen share audio */}
{p.screenAudioTrack && (
{/* Screen share audio — only when user opted in to watch */}
{p.screenAudioTrack && watchingStreams.has(p.userId) && (
<AudioTrackElement
track={p.screenAudioTrack}
globalVolume={outputVolume}
@@ -109,7 +109,7 @@ export function StreamTile({ tile, large }) {
const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled);
const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength);
const hasVideo = liveScreenTrack !== null;
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("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", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: true, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("div", { className: "absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("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", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
/* Streamer context menu (own stream) */
_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => {
handleStopStreaming();
@@ -143,7 +143,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
muted
className="w-full h-full object-contain bg-black"
/>
) : (
+13 -3
View File
@@ -1,5 +1,5 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { VoiceUser } from './VoiceUser';
import { StreamTile } from './StreamTile';
import { useVoiceStore } from '../../stores/voiceStore';
@@ -7,7 +7,12 @@ import { deriveGridTiles } from '../../hooks/useLiveKit';
export function VoiceGrid({ participants }) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const [stripHidden, setStripHidden] = useState(false);
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
// Reset strip visibility when focus target changes
useEffect(() => {
setStripHidden(false);
}, [focusedParticipantId]);
// Unfocus if the focused stream tile no longer exists
useEffect(() => {
const currentStreamKeys = new Set(tiles
@@ -30,10 +35,15 @@ export function VoiceGrid({ participants }) {
// Focus mode: one large tile + bottom strip
if (focusedTile) {
const otherTiles = tiles.filter((t) => t.key !== focusedParticipantId);
return (_jsxs("div", { className: "flex-1 flex flex-col overflow-hidden relative", children: [_jsxs("div", { className: "flex-1 p-2 min-h-0 cursor-pointer", onClick: () => setFocusedParticipant(null), title: "Click to return to grid view", children: [renderTile(focusedTile, true), _jsxs("button", { onClick: (e) => {
return (_jsxs("div", { className: "flex-1 flex flex-col overflow-hidden relative", children: [_jsxs("div", { className: "flex-1 p-2 min-h-0 cursor-pointer relative", onClick: () => setFocusedParticipant(null), title: "Click to return to grid view", children: [renderTile(focusedTile, true), _jsxs("button", { onClick: (e) => {
e.stopPropagation();
setFocusedParticipant(null);
}, className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherTiles.length > 0 && (_jsx("div", { className: "h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar", children: otherTiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity", children: renderTile(t) }, t.key))) }))] }));
}, className: "absolute top-4 right-4 z-10 px-3 py-1.5 bg-black/60 hover:bg-black/80 rounded-lg flex items-center gap-2 text-white/70 hover:text-white transition-colors", title: "Back to grid view", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M3 3h8v8H3V3zm0 10h8v8H3v-8zm10-10h8v8h-8V3zm0 10h8v8h-8v-8z" }) }), _jsx("span", { className: "text-xs font-medium", children: "Grid" })] })] }), otherTiles.length > 0 && (_jsx("div", { className: "flex justify-center flex-shrink-0 py-1", children: _jsxs("button", { onClick: (e) => {
e.stopPropagation();
setStripHidden(!stripHidden);
}, className: "px-4 py-1 bg-black/50 hover:bg-black/70 rounded-full flex items-center gap-2 text-white/60 hover:text-white transition-colors text-xs", title: stripHidden ? 'Show Members' : 'Hide Members', children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: stripHidden
? _jsx("path", { d: "M7 14l5-5 5 5z" })
: _jsx("path", { d: "M7 10l5 5 5-5z" }) }), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }), _jsx("span", { children: stripHidden ? 'Show Members' : 'Hide Members' })] }) })), !stripHidden && otherTiles.length > 0 && (_jsx("div", { className: "h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar", children: otherTiles.map((t) => (_jsx("div", { onClick: () => setFocusedParticipant(t.key), className: "h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity", children: renderTile(t) }, t.key))) }))] }));
}
// Default grid mode
const gridClass = (() => {
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { VoiceUser } from './VoiceUser';
import { StreamTile } from './StreamTile';
import { useVoiceStore } from '../../stores/voiceStore';
@@ -12,9 +12,15 @@ interface VoiceGridProps {
export function VoiceGrid({ participants }: VoiceGridProps) {
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
const [stripHidden, setStripHidden] = useState(false);
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
// Reset strip visibility when focus target changes
useEffect(() => {
setStripHidden(false);
}, [focusedParticipantId]);
// Unfocus if the focused stream tile no longer exists
useEffect(() => {
const currentStreamKeys = new Set(
@@ -75,12 +81,12 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Main focused view */}
<div
className="flex-1 p-2 min-h-0 cursor-pointer"
className="flex-1 p-2 min-h-0 cursor-pointer relative"
onClick={() => setFocusedParticipant(null)}
title="Click to return to grid view"
>
{renderTile(focusedTile, true)}
{/* Back to grid button */}
{/* Grid button — top-right */}
<button
onClick={(e) => {
e.stopPropagation();
@@ -101,8 +107,33 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
</button>
</div>
{/* Bottom strip of other tiles */}
{/* Centered Hide/Show Members button — divider between focused tile and strip */}
{otherTiles.length > 0 && (
<div className="flex justify-center flex-shrink-0 py-1">
<button
onClick={(e) => {
e.stopPropagation();
setStripHidden(!stripHidden);
}}
className="px-4 py-1 bg-black/50 hover:bg-black/70 rounded-full flex items-center gap-2 text-white/60 hover:text-white transition-colors text-xs"
title={stripHidden ? 'Show Members' : 'Hide Members'}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
{stripHidden
? <path d="M7 14l5-5 5 5z" />
: <path d="M7 10l5 5 5-5z" />
}
</svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
<span>{stripHidden ? 'Show Members' : 'Hide Members'}</span>
</button>
</div>
)}
{/* Bottom strip of other tiles */}
{!stripHidden && otherTiles.length > 0 && (
<div className="h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar">
{otherTiles.map((t) => (
<div