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.
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
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;
|
||
}
|