{"version":3,"file":"audio-bus.cjs","names":[],"sources":["../../src/audio/audio-bus.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mixing graph, the per-source handle and the\n * output route are one object's lifetime: the limiter only makes sense after the\n * sum, and the element that carries `setSinkId` is the same one the mix leaves\n * through. Splitting them would hand the caller two halves that are useless apart.\n */\nimport { isAudioOutputSelectionSupported, setAudioOutput } from \"./audio-output\";\n\n/** Default ceiling for a per-source or master gain, as a multiplier. */\nexport const DEFAULT_MAX_GAIN = 3;\n\n/** Shape of the master limiter. Matches `DynamicsCompressorNode`'s params. */\nexport interface LimiterSettings {\n    /** dBFS above which the compressor starts working. */\n    threshold: number;\n    /** dB range over which the curve bends. `0` is a hard knee. */\n    knee: number;\n    /** Input/output ratio above the threshold. 20 is limiting, not compression. */\n    ratio: number;\n    /** Seconds to clamp a peak. */\n    attack: number;\n    /** Seconds to let go. */\n    release: number;\n}\n\n/**\n * A limiter, not a compressor: a hard knee at a 20:1 ratio with a 3 ms attack\n * catches the sum before it clips without audibly ducking anything below it.\n */\nconst DEFAULT_LIMITER: LimiterSettings = {\n    threshold: -8,\n    knee: 0,\n    ratio: 20,\n    attack: 0.003,\n    release: 0.25,\n};\n\n/** Options for {@link createAudioBus}. */\nexport interface AudioBusOptions {\n    /** Ceiling for every gain on this bus. Default {@link DEFAULT_MAX_GAIN}. */\n    maxGain?: number;\n    /** Override the master limiter, or pass `false` to run without one. */\n    limiter?: Partial<LimiterSettings> | false;\n    /**\n     * Reuse an existing `AudioContext` instead of creating one.\n     *\n     * Browsers cap the number of live contexts (Chrome allows around six), so a\n     * page that already has one — a level meter, a player — should hand it over\n     * rather than open a second.\n     */\n    context?: AudioContext;\n}\n\n/** One source attached to the bus. */\nexport interface AudioBusHandle {\n    /**\n     * Set this source's gain, where `1` is the level it arrived at.\n     *\n     * Values above `1` are the point of the whole graph: `element.volume` is\n     * clamped at `1`, so a quiet talker could only ever be attenuated — the one\n     * correction nobody needs.\n     */\n    setGain: (gain: number) => void;\n    /** Current gain, after clamping. */\n    readonly gain: number;\n    /** Detach this source and release its nodes. The bus stays up. */\n    stop: () => void;\n}\n\n/** A running mix. */\nexport interface AudioBus {\n    /**\n     * Play a stream through the shared mix.\n     *\n     * @param stream - The stream to play. Only its first audio track is used.\n     * @param options - `gain` is the initial multiplier, default `1`.\n     * @returns A handle to adjust or detach this source.\n     */\n    attach: (stream: MediaStream, options?: { gain?: number }) => AudioBusHandle;\n    /** Scale every source, on top of its own gain. */\n    setMasterGain: (gain: number) => void;\n    /** Current master gain, after clamping. */\n    readonly masterGain: number;\n    /**\n     * Route the whole mix to one output device.\n     *\n     * @param deviceId - Device id from `useMediaDevices().audioOutputs`, or `\"\"`\n     *   for the system default.\n     * @returns `false` when the engine cannot route audio, or the device is gone.\n     */\n    setOutputDevice: (deviceId: string) => Promise<boolean>;\n    /** Device id the mix is routed to. `\"\"` is the system default. */\n    readonly outputDevice: string;\n    /**\n     * Resume a context the browser started suspended.\n     *\n     * Autoplay policy suspends a context created outside a user gesture, and a\n     * suspended context is silent with no error anywhere. Call this from the\n     * click that starts playback.\n     */\n    resume: () => Promise<void>;\n    /**\n     * Detach everything and close the context this bus created.\n     *\n     * Idempotent, and the bus stays callable afterwards: `attach` hands back an\n     * inert handle instead of throwing. That is not politeness — creating a node\n     * on a closed `AudioContext` throws `InvalidStateError`, and the stream that\n     * arrives late is the normal case (a WebRTC `ontrack` firing after the\n     * component that owned the bus went away).\n     */\n    close: () => void;\n    /** Whether this browser gave us a Web Audio graph at all. */\n    readonly supported: boolean;\n    /** Whether this browser can route the mix to a chosen output device. */\n    readonly canSelectOutput: boolean;\n}\n\n/**\n * Clamp a gain into `0..max`, treating a non-finite value as unity.\n *\n * `NaN` reaches here from an empty input or a failed parse, and assigning it to\n * an `AudioParam` throws — losing the audio for a typo in a number field.\n */\nfunction clampGain(gain: number, max: number): number {\n    if (!Number.isFinite(gain)) return 1;\n    return Math.min(max, Math.max(0, gain));\n}\n\n/** The `AudioContext` constructor this engine exposes, prefixed or not. */\nfunction audioContextConstructor(): typeof AudioContext | undefined {\n    if (typeof AudioContext !== \"undefined\") return AudioContext;\n    return (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n}\n\n/** A media element that may support output routing. */\ntype SinkCapableElement = HTMLAudioElement & { setSinkId?: (sinkId: string) => Promise<void> };\n\n/**\n * The stream to feed the graph: just the audio track, when this engine can build\n * one.\n *\n * Isolating the track keeps a camera's video out of the source node. `MediaStream`\n * is missing outside a browser (jsdom, a worker), and there the original stream is\n * the right answer anyway — `createMediaStreamSource` reads its first audio track.\n */\nfunction audioOnly(stream: MediaStream, track: MediaStreamTrack): MediaStream {\n    if (typeof MediaStream === \"undefined\") return stream;\n    return new MediaStream([track]);\n}\n\n/** Start playback without letting an environment that has no media pipeline throw. */\nfunction playQuietly(element: HTMLAudioElement): void {\n    try {\n        void element.play?.()?.catch(() => undefined);\n    } catch {\n        /* jsdom and any engine without a media pipeline — nothing to play anyway */\n    }\n}\n\n/**\n * Mix several streams into one output, with gain above 100% and a master limiter.\n *\n * Three things make this a graph instead of a few `<audio>` elements:\n *\n * 1. **`element.volume` is clamped at `1`.** A participant who speaks too quietly\n *    can only be turned *down* — the one correction nobody needs. A `GainNode`\n *    has no ceiling, so this bus takes one (`maxGain`, default 3).\n * 2. **Clipping is a property of the sum.** Three sources at 200% each are clean\n *    alone and distort the instant they play together. A per-source limiter\n *    cannot see that; the one after the mix can, which is where this puts it.\n * 3. **`setSinkId` lives on the element, not on the context.** Sending the mix to\n *    a headset while the rest of the system keeps the speakers is only reachable\n *    by leaving through a `MediaStreamAudioDestinationNode` into a real\n *    `<audio>`. `AudioContext.setSinkId` has far thinner support.\n *\n * @param options - See {@link AudioBusOptions}.\n * @returns The bus. On an engine with no Web Audio it is inert but callable, and\n *   `supported` is `false` — a page without sound beats a page that throws.\n *\n * @example\n * const bus = createAudioBus({ maxGain: 3 });\n * const handle = bus.attach(remoteStream, { gain: 1 });\n * handle.setGain(2.4);              // above 1 — the point of the whole thing\n * await bus.setOutputDevice(headsetId);\n */\nexport function createAudioBus({\n    maxGain = DEFAULT_MAX_GAIN,\n    limiter: limiterOptions,\n    context: injectedContext,\n}: AudioBusOptions = {}): AudioBus {\n    const Ctor = audioContextConstructor();\n    const context = injectedContext ?? (Ctor ? new Ctor() : null);\n\n    if (!context) {\n        return {\n            attach: () => ({ setGain: () => undefined, gain: 1, stop: () => undefined }),\n            setMasterGain: () => undefined,\n            masterGain: 1,\n            setOutputDevice: async () => false,\n            outputDevice: \"\",\n            resume: async () => undefined,\n            close: () => undefined,\n            supported: false,\n            canSelectOutput: false,\n        };\n    }\n\n    const graph = context;\n    const ownsContext = !injectedContext;\n    const master = graph.createGain();\n    const destination = graph.createMediaStreamDestination();\n\n    let limiterNode: DynamicsCompressorNode | null = null;\n    if (limiterOptions === false) {\n        master.connect(destination);\n    } else {\n        const limiter = graph.createDynamicsCompressor();\n        limiterNode = limiter;\n        const settings = { ...DEFAULT_LIMITER, ...limiterOptions };\n        limiter.threshold.value = settings.threshold;\n        limiter.knee.value = settings.knee;\n        limiter.ratio.value = settings.ratio;\n        limiter.attack.value = settings.attack;\n        limiter.release.value = settings.release;\n        master.connect(limiter);\n        limiter.connect(destination);\n    }\n\n    const element = new Audio();\n    element.autoplay = true;\n    element.srcObject = destination.stream;\n    playQuietly(element);\n\n    let sinkId = \"\";\n    let masterGain = 1;\n    let closed = false;\n    const handles = new Set<AudioBusHandle>();\n\n    /** The handle a bus hands out when there is nothing to play through. */\n    function inertHandle(): AudioBusHandle {\n        return { setGain: () => undefined, gain: 1, stop: () => undefined };\n    }\n\n    /**\n     * Play one stream through the mix.\n     *\n     * The muted `<audio>` anchor is not dead weight, and deleting it as unused is\n     * the mistake this comment exists to prevent: Chrome will not pull samples\n     * from a `MediaStreamAudioSourceNode` built over a **remote** WebRTC stream\n     * unless that same stream is also attached to a media element\n     * (crbug.com/687574). Without it the graph is visibly correct and completely\n     * silent — a day of debugging for anyone who does not know the bug. It is\n     * muted because the audible copy is the one leaving the bus.\n     */\n    function attach(stream: MediaStream, { gain = 1 }: { gain?: number } = {}): AudioBusHandle {\n        const track = stream.getAudioTracks()[0];\n        if (closed || !track) return inertHandle();\n\n        const anchor = new Audio();\n        anchor.muted = true;\n        anchor.autoplay = true;\n        anchor.srcObject = stream;\n        playQuietly(anchor);\n\n        const source = graph.createMediaStreamSource(audioOnly(stream, track));\n        const node = graph.createGain();\n        let current = clampGain(gain, maxGain);\n        node.gain.value = current;\n        source.connect(node);\n        node.connect(master);\n\n        const handle: AudioBusHandle = {\n            setGain(next: number): void {\n                current = clampGain(next, maxGain);\n                node.gain.value = current;\n            },\n            get gain(): number {\n                return current;\n            },\n            stop(): void {\n                if (!handles.has(handle)) return;\n                handles.delete(handle);\n                source.disconnect();\n                node.disconnect();\n                anchor.srcObject = null;\n                anchor.pause();\n            },\n        };\n        handles.add(handle);\n        return handle;\n    }\n\n    return {\n        attach,\n        setMasterGain(gain: number): void {\n            if (closed) return;\n            masterGain = clampGain(gain, maxGain);\n            master.gain.value = masterGain;\n        },\n        get masterGain(): number {\n            return masterGain;\n        },\n        async setOutputDevice(deviceId: string): Promise<boolean> {\n            const applied = await setAudioOutput(element as SinkCapableElement, deviceId);\n            if (applied) sinkId = deviceId;\n            return applied;\n        },\n        get outputDevice(): string {\n            return sinkId;\n        },\n        async resume(): Promise<void> {\n            await graph.resume?.().catch(() => undefined);\n        },\n        close(): void {\n            if (closed) return;\n            closed = true;\n            for (const handle of [...handles]) handle.stop();\n            element.srcObject = null;\n            element.pause();\n            master.disconnect();\n            limiterNode?.disconnect();\n            if (ownsContext) void graph.close().catch(() => undefined);\n        },\n        supported: true,\n        get canSelectOutput(): boolean {\n            return isAudioOutputSelectionSupported();\n        },\n    };\n}\n"],"mappings":"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"}