// Central registry of Web-Audio pipelines for every remote audio track we're // playing. Exists because `HTMLMediaElement.volume` caps at 1.0 — to let a // user boost a quiet peer past 100% we need an explicit GainNode in the // output chain. Once a track is on the WebAudio path, `audio.muted` / volume // no longer drive output (the MediaElementSource diverts samples through the // graph), so all gating — deafen, watch-state, manual mute, per-user volume — // is collapsed into a single effective-gain value per pipeline. // // The registry is a plain module-level Map. Attach/detach lifecycle is owned // by CallContext's attachTrack/detachTrack helpers; gain recomputation is // also triggered from CallContext when any state the formula depends on // changes. export type RemoteTrackSource = 'microphone' | 'screenshare'; export interface RemoteAudioPipeline { /** LiveKit track sid — stable identifier for this track's lifetime. */ trackSid: string; participantId: string; trackSource: RemoteTrackSource; audio: HTMLAudioElement; ctx: AudioContext; source: MediaElementAudioSourceNode; gain: GainNode; } const pipelines = new Map(); // Build the WebAudio chain for an already-attached HTMLAudioElement. Returns // null when WebAudio isn't available (older browsers) — callers should fall // back to `audio.volume` in that case. export function createPipeline( audio: HTMLAudioElement, info: { trackSid: string; participantId: string; trackSource: RemoteTrackSource }, ): RemoteAudioPipeline | null { const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioCtx) return null; try { const ctx = new AudioCtx(); // A fresh AudioContext under Chromium's autoplay policy starts in // `suspended` state when no recent user gesture is in scope — // attachTrack fires from a LiveKit event, not the call-start click, // so we can't rely on the gesture crossing the async boundary. Kick // resume() immediately and retry on any statechange so a later // suspension (window backgrounding, device change) doesn't leave // the remote peer silent permanently. const tryResume = () => { if (ctx.state === 'suspended') { void ctx.resume().catch(() => { /* ignore — will retry on next statechange */ }); } }; tryResume(); ctx.addEventListener('statechange', tryResume); const source = ctx.createMediaElementSource(audio); const gain = ctx.createGain(); // Start silent; the caller (CallContext) applies the correct effective // gain immediately after registering via `setPipelineGain`. gain.gain.value = 0; source.connect(gain); gain.connect(ctx.destination); // Do NOT set `audio.muted = true` here. Chromium gates the // media-element's internal sample production behind the muted flag, // and that gate sits *before* the MediaElementAudioSourceNode tap — // a muted element feeds zero samples into the WebAudio graph, which // silences the peer even though createMediaElementSource already // diverts the element's direct playback path. The diversion itself // is sufficient to stop the element from double-playing to the // default output; explicit muting is the bug. const pipeline: RemoteAudioPipeline = { trackSid: info.trackSid, participantId: info.participantId, trackSource: info.trackSource, audio, ctx, source, gain, }; pipelines.set(info.trackSid, pipeline); return pipeline; } catch (err: unknown) { console.warn('createPipeline failed', err); return null; } } export function destroyPipeline(trackSid: string): void { const p = pipelines.get(trackSid); if (!p) return; pipelines.delete(trackSid); try { p.source.disconnect(); } catch { /* ignore */ } try { p.gain.disconnect(); } catch { /* ignore */ } void p.ctx.close().catch(() => { /* ignore */ }); } export function allPipelines(): IterableIterator { return pipelines.values(); } export function getPipeline(trackSid: string): RemoteAudioPipeline | undefined { return pipelines.get(trackSid); } export function pipelinesFor( participantId: string, source?: RemoteTrackSource, ): RemoteAudioPipeline[] { const out: RemoteAudioPipeline[] = []; for (const p of pipelines.values()) { if (p.participantId !== participantId) continue; if (source && p.trackSource !== source) continue; out.push(p); } return out; } // Ramp the gain slightly (~20ms) so 0→2x doesn't introduce a click and so // rapid slider drags stay smooth. ctx.currentTime is the right anchor — // setValueAtTime jumps abruptly. export function setPipelineGain(pipeline: RemoteAudioPipeline, value: number): void { const v = clampGain(value); try { pipeline.gain.gain.setTargetAtTime(v, pipeline.ctx.currentTime, 0.02); } catch { // Some contexts in a closed state throw — just direct-assign as a // fallback; worst case the next valid call smooths it out. pipeline.gain.gain.value = v; } } export function clampGain(v: number): number { if (!Number.isFinite(v)) return 0; if (v < 0) return 0; // 2.0 matches Discord's upper bound (200%). Going higher invites clipping // since the source is already peaking at 1.0 for most mics. if (v > 2) return 2; return v; } // Route every pipeline's AudioContext to the given sink. Feature-detects // `AudioContext.setSinkId` (Chrome 115+); older runtimes silently keep the // default sink, which is the behaviour we had before the WebAudio refactor // so this is a strict improvement rather than a regression. export async function setAllPipelinesSinkId(deviceId: string): Promise { const maybeId = deviceId.length === 0 ? 'default' : deviceId; for (const p of pipelines.values()) { const ctxAny = p.ctx as unknown as { setSinkId?: (id: string) => Promise; }; if (typeof ctxAny.setSinkId !== 'function') continue; try { await ctxAny.setSinkId(maybeId); } catch (err: unknown) { console.warn('AudioContext.setSinkId failed', { trackSid: p.trackSid, err }); } } }