Files
backspace/packages/web/src/hooks/useAudioTrackPlayer.ts
T
Jannis Braun 5c38f076d3 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.
2026-02-20 07:03:41 +01:00

111 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useEffect } from 'react';
import { AudioManager } from '../audio/AudioManager';
interface UseAudioTrackPlayerOptions {
track: MediaStreamTrack | null;
volume: number; // final computed value, 0.0 4.0+
muted: boolean; // deafen, stream mute, etc.
}
/**
* Hybrid audio pipeline for a single remote audio track.
*
* Architecture:
* 1. A MUTED <audio> element keeps Chrome's WebRTC audio pipeline alive
* for the track. Chrome requires an HTML media element consuming a
* WebRTC MediaStreamTrack or it stops processing it. The element is
* always muted (volume=0, muted=true) — it never produces audible output.
*
* 2. A Web Audio pipeline handles ALL actual audio output:
* MediaStreamTrack -> MediaStream -> MediaStreamAudioSourceNode -> GainNode -> ctx.destination
*
* This gives us:
* - Chrome compatibility (muted <audio> keep-alive)
* - No ducking (all elements are muted, only Web Audio produces sound)
* - Clean mixing (single ctx.destination for all tracks)
* - Full volume range (0.0 4.0+) via GainNode
* - Smooth transitions via setTargetAtTime (no clicks/pops)
*/
export function useAudioTrackPlayer(
opts: UseAudioTrackPlayerOptions,
): React.RefObject<HTMLAudioElement> {
const { track, volume, muted } = opts;
const audioRef = useRef<HTMLAudioElement | null>(null);
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const gainRef = useRef<GainNode | null>(null);
// Keep current volume/muted in refs so Effect 1 can read them
// for the initial ramp without depending on them
const volumeRef = useRef(volume);
const mutedRef = useRef(muted);
volumeRef.current = volume;
mutedRef.current = muted;
// Effect 1: Track attachment (keep-alive) + Web Audio pipeline build
useEffect(() => {
const audioEl = audioRef.current;
// Tear down previous Web Audio graph
if (sourceRef.current) {
sourceRef.current.disconnect();
sourceRef.current = null;
}
if (gainRef.current) {
gainRef.current.disconnect();
gainRef.current = null;
}
if (!track) {
if (audioEl) audioEl.srcObject = null;
return;
}
// --- Keep-alive: attach track to <audio> element (always muted) ---
// Chrome needs an HTML element consuming the WebRTC track or it
// stops the audio pipeline for that track entirely.
if (audioEl) {
audioEl.srcObject = new MediaStream([track]);
audioEl.muted = true;
audioEl.volume = 0;
audioEl.play().catch(() => {});
}
// --- Web Audio pipeline for actual output ---
const ctx = AudioManager.getInstance().ensureContext();
const stream = new MediaStream([track]);
const source = ctx.createMediaStreamSource(stream);
const gain = ctx.createGain();
// Start gain at 0 to prevent pop, then ramp to target
gain.gain.setValueAtTime(0, ctx.currentTime);
const targetGain = mutedRef.current ? 0 : volumeRef.current;
gain.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
source.connect(gain);
gain.connect(AudioManager.getInstance().getMasterOutput());
sourceRef.current = source;
gainRef.current = gain;
return () => {
source.disconnect();
gain.disconnect();
sourceRef.current = null;
gainRef.current = null;
};
}, [track]);
// Effect 2: Update gain when volume or muted changes (no graph rebuild)
useEffect(() => {
if (!gainRef.current) return;
const ctx = AudioManager.getInstance().ensureContext();
const targetGain = muted ? 0 : volume;
gainRef.current.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
}, [volume, muted]);
return audioRef;
}