// Shared Web Audio graph that sits between the raw microphone MediaStream // and LiveKit's published track. Mixes live mic with on-demand soundboard // buffers so both paths reach the peer through a single published track, // and lets us locally monitor soundboard output without feedback from the // mic path. // // Graph: // rawMicSource ──► micGain ──┐ // ├─► destinationNode ─► publishedTrack // sbBufferSources ─► sbGain ─┤ // └─► monitorGain ─► ctx.destination (local hear, // soundboard only) // // Lifetime: // createMicPipeline(rawTrack) — builds graph + AudioContext // pipeline.outputTrack — pass to `localParticipant.publishTrack` // pipeline.setMicGain(0..1) — mute / PTT // pipeline.setSoundboardGain(..) / setMonitorGain(..) — sb master + local hear // pipeline.playBuffer(buffer, opts) — returns a handle so callers can stop // pipeline.stopAll(buffers?) — kill every active sb source (or only one id) // pipeline.replaceMicTrack(newTrack) — hot-swap on device change // pipeline.destroy() — close ctx, stop owned tracks export interface PlayBufferOpts { /** Per-source gain 0..1, multiplied by sb master. */ gain?: number; /** Stable id — calling playBuffer with the same id stops the previous one * first (single-fire mode). Omit for overlap mode. */ id?: string; /** Fired when the buffer ends naturally (not when stopped manually). */ onEnded?: () => void; } export interface PlayHandle { id: string | null; stop(): void; } export interface MicPipeline { readonly outputTrack: MediaStreamTrack; setMicGain(value: number): void; setSoundboardGain(value: number): void; setMonitorGain(value: number): void; replaceMicTrack(newTrack: MediaStreamTrack): void; playBuffer(buffer: AudioBuffer, opts?: PlayBufferOpts): PlayHandle; stopAll(id?: string): void; destroy(): void; } interface ActiveSource { id: string | null; node: AudioBufferSourceNode; gain: GainNode; } // Clamp helper — avoid letting callers pass NaN or out-of-range values. function clamp01(v: number): number { if (!Number.isFinite(v)) return 0; if (v < 0) return 0; if (v > 1) return 1; return v; } export function createMicPipeline(rawTrack: MediaStreamTrack): MicPipeline { const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioCtx) { throw new Error('AudioContext unavailable'); } const ctx = new AudioCtx(); const micGain = ctx.createGain(); micGain.gain.value = 1; const sbGain = ctx.createGain(); sbGain.gain.value = 1; const monitorGain = ctx.createGain(); monitorGain.gain.value = 1; const dest = ctx.createMediaStreamDestination(); // Mic path → published only. micGain.connect(dest); // Soundboard path → published + local monitor. sbGain.connect(dest); sbGain.connect(monitorGain); monitorGain.connect(ctx.destination); let currentRawTrack: MediaStreamTrack = rawTrack; let micSource: MediaStreamAudioSourceNode = buildMicSource(ctx, rawTrack, micGain); const active = new Set(); let destroyed = false; function buildMicSource( c: AudioContext, t: MediaStreamTrack, target: AudioNode, ): MediaStreamAudioSourceNode { const stream = new MediaStream([t]); const node = c.createMediaStreamSource(stream); node.connect(target); return node; } const outputTrack = dest.stream.getAudioTracks()[0]; if (!outputTrack) { throw new Error('MediaStreamAudioDestinationNode produced no audio track'); } return { outputTrack, setMicGain(value: number) { if (destroyed) return; const v = clamp01(value); micGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01); }, setSoundboardGain(value: number) { if (destroyed) return; const v = clamp01(value); sbGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01); }, setMonitorGain(value: number) { if (destroyed) return; const v = clamp01(value); monitorGain.gain.setTargetAtTime(v, ctx.currentTime, 0.01); }, replaceMicTrack(newTrack: MediaStreamTrack) { if (destroyed) return; // Tear down the old MediaStreamSourceNode and stop the raw track we // owned. Caller passes ownership of `newTrack` to the pipeline. try { micSource.disconnect(); } catch { /* ignore */ } if (currentRawTrack !== newTrack) { try { currentRawTrack.stop(); } catch { /* ignore */ } } currentRawTrack = newTrack; micSource = buildMicSource(ctx, newTrack, micGain); }, playBuffer(buffer: AudioBuffer, opts: PlayBufferOpts = {}): PlayHandle { if (destroyed) { return { id: opts.id ?? null, stop: () => undefined }; } // Single-fire: stop previous instance of the same id so holding a // hotkey doesn't stack a dozen overlapping plays. if (opts.id) { for (const entry of active) { if (entry.id === opts.id) stopActive(entry); } } const node = ctx.createBufferSource(); node.buffer = buffer; const g = ctx.createGain(); g.gain.value = clamp01(opts.gain ?? 1); node.connect(g); g.connect(sbGain); const entry: ActiveSource = { id: opts.id ?? null, node, gain: g }; active.add(entry); node.onended = () => { if (!active.has(entry)) return; try { node.disconnect(); } catch { /* ignore */ } try { g.disconnect(); } catch { /* ignore */ } active.delete(entry); opts.onEnded?.(); }; try { node.start(); } catch { active.delete(entry); } return { id: entry.id, stop: () => stopActive(entry), }; }, stopAll(id?: string) { for (const entry of Array.from(active)) { if (id !== undefined && entry.id !== id) continue; stopActive(entry); } }, destroy() { if (destroyed) return; destroyed = true; for (const entry of Array.from(active)) stopActive(entry); try { micSource.disconnect(); } catch { /* ignore */ } try { micGain.disconnect(); } catch { /* ignore */ } try { sbGain.disconnect(); } catch { /* ignore */ } try { monitorGain.disconnect(); } catch { /* ignore */ } try { currentRawTrack.stop(); } catch { /* ignore */ } try { outputTrack.stop(); } catch { /* ignore */ } void ctx.close().catch(() => { /* ignore */ }); }, }; function stopActive(entry: ActiveSource): void { try { entry.node.onended = null; entry.node.stop(); } catch { /* already ended */ } try { entry.node.disconnect(); } catch { /* ignore */ } try { entry.gain.disconnect(); } catch { /* ignore */ } active.delete(entry); } }