{"version":3,"file":"voice-chain.cjs","names":[],"sources":["../../src/audio/voice-chain.ts"],"sourcesContent":["import {\n    compressorStage,\n    deEsserStage,\n    gateStage,\n    highPassStage,\n    hissCutStage,\n    limiterStage,\n    presenceStage,\n} from \"./voice-chain-stages\";\n\n/** Which stages a voice chain builds. */\nexport interface VoiceChainSettings {\n    /** Cut everything below speech: fans, traffic rumble, desk knocks, plosives. */\n    highPass: boolean;\n    /** Silence the line when nobody is talking. */\n    gate: boolean;\n    /** RMS below which the gate closes, 0..1. See {@link suggestGateThreshold}. */\n    gateThreshold: number;\n    /** Even out the distance between a whisper and a shout. */\n    compressor: boolean;\n    /** Lift the consonant band so speech reads without getting louder. */\n    presence: boolean;\n    /** Roll off hiss above what speech uses. */\n    hissCut: boolean;\n    /** Ride down harsh S and CH sounds. */\n    deEsser: boolean;\n    /**\n     * Hard ceiling before the signal leaves.\n     *\n     * On by default and worth leaving on: the leveller is 3:1, which controls the\n     * average and lets a peak through, and `gain` can reach well above 1.\n     */\n    limiter: boolean;\n}\n\n/**\n * What a call wants before anybody touches a slider.\n *\n * High-pass, leveller and limiter only. Gate, presence, hiss cut and de-esser are\n * off because each has an audible cost when it is not needed — a gate set for the\n * wrong room clips words, presence on a bright microphone is harsh, and a de-esser\n * on a voice that does not hiss just dulls it.\n */\nexport const DEFAULT_VOICE_CHAIN: VoiceChainSettings = {\n    highPass: true,\n    gate: false,\n    gateThreshold: 0.02,\n    compressor: true,\n    presence: false,\n    hissCut: false,\n    deEsser: false,\n    limiter: true,\n};\n\n/** Options for {@link createVoiceChain}. */\nexport interface VoiceChainOptions {\n    /** Output multiplier, applied after every stage and before the limiter. Default `1`. */\n    gain?: number;\n    /**\n     * Reuse an existing `AudioContext` instead of creating one.\n     *\n     * Browsers cap live contexts (Chrome allows around six), so a page that already\n     * has one — a level meter, an audio bus — should hand it over.\n     */\n    context?: AudioContext;\n    /**\n     * Whether `release()` also stops `source`. Default `true`.\n     *\n     * Pass `false` when the track belongs to somebody else: a settings dialog\n     * building a second chain over its live preview would otherwise kill the meter\n     * the person is watching while they listen.\n     */\n    ownsSource?: boolean;\n}\n\n/** A running voice chain. */\nexport interface VoiceChain {\n    /** The processed track to publish. */\n    track: MediaStreamTrack;\n    /** Stop the detectors, disconnect the graph, and release the track. */\n    release: () => void;\n    /**\n     * Whether a graph was actually built.\n     *\n     * `false` when this engine has no Web Audio, and also when the settings asked\n     * for nothing — in both cases `track` is `source`, untouched.\n     */\n    readonly supported: boolean;\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/**\n * Whether any of this would change the signal.\n *\n * A chain that only passes audio through still costs a re-encode and a handful of\n * nodes, so a caller that rebuilds on every settings change should ask first —\n * and {@link createVoiceChain} asks too, handing back `source` rather than\n * building a graph that does nothing.\n *\n * @param settings - The stages under consideration.\n * @param gain - The output multiplier that would be applied. Default `1`.\n * @returns `true` when the chain would be audibly transparent.\n */\nexport function isVoiceChainIdle(settings: VoiceChainSettings, gain = 1): boolean {\n    return (\n        !settings.highPass &&\n        !settings.gate &&\n        !settings.compressor &&\n        !settings.presence &&\n        !settings.hissCut &&\n        !settings.deEsser &&\n        !settings.limiter &&\n        gain === 1\n    );\n}\n\n/** A chain that owns nothing, for an engine or a settings object with no work to do. */\nfunction passThrough(source: MediaStreamTrack, ownsSource: boolean): VoiceChain {\n    return {\n        track: source,\n        release: () => {\n            if (ownsSource) source.stop();\n        },\n        supported: false,\n    };\n}\n\n/**\n * Build the processing a microphone is published through.\n *\n * Ordered the way a console is: cut what is not speech, gate what is left, level\n * it, shape it, set how loud it goes out, then hold a ceiling. **The order is not\n * cosmetic.** Gating before the high-pass lets a rumble hold the gate open;\n * compressing before the gate lifts the noise floor up to meet the threshold; and\n * a limiter placed before the output gain is a ceiling the gain then walks\n * straight through.\n *\n * This sits *after* the browser's own echo cancellation and noise suppression,\n * which stay on: the two solve different problems. The browser's is trained on\n * stationary noise inside the capture pipeline; this cuts what it leaves behind\n * and decides when the line should be silent at all.\n *\n * @param source - The captured microphone track.\n * @param settings - Which stages to build. Default {@link DEFAULT_VOICE_CHAIN}.\n * @param options - See {@link VoiceChainOptions}.\n * @returns The track to publish plus its teardown. On an engine with no Web Audio,\n *   and when every stage is off, the source track is handed back untouched and\n *   `supported` is `false` — a call without processing beats a call that throws.\n *   The idle case is decided **before** a context is opened, because browsers cap\n *   how many can be live and one built for a chain that does nothing still counts.\n *\n * @example\n * const floor = await measureNoiseFloor(stream);\n * const chain = createVoiceChain(micTrack, {\n *     ...DEFAULT_VOICE_CHAIN,\n *     gate: true,\n *     gateThreshold: suggestGateThreshold(floor),\n * }, { gain: 1.4 });\n * await mesh.setLocalTrack(\"mic\", chain.track);\n */\nexport function createVoiceChain(\n    source: MediaStreamTrack,\n    settings: VoiceChainSettings = DEFAULT_VOICE_CHAIN,\n    { gain = 1, context: injectedContext, ownsSource = true }: VoiceChainOptions = {},\n): VoiceChain {\n    if (isVoiceChainIdle(settings, gain)) return passThrough(source, ownsSource);\n\n    const Ctor = audioContextConstructor();\n    const context = injectedContext ?? (Ctor ? new Ctor() : null);\n    if (!context) return passThrough(source, ownsSource);\n\n    const input = context.createMediaStreamSource(\n        typeof MediaStream === \"undefined\"\n            ? (source as unknown as MediaStream)\n            : new MediaStream([source]),\n    );\n    const built: AudioNode[] = [input];\n    const stops: (() => void)[] = [];\n    let node: AudioNode = input;\n\n    const chain = (next: AudioNode): void => {\n        node.connect(next);\n        node = next;\n        built.push(next);\n    };\n\n    if (settings.highPass) chain(highPassStage(context));\n\n    if (settings.gate) {\n        const stage = gateStage(context, () => settings.gateThreshold);\n        node.connect(stage.detector);\n        built.push(stage.detector);\n        stops.push(stage.stop);\n        chain(stage.gain);\n    }\n\n    if (settings.compressor) chain(compressorStage(context));\n    if (settings.presence) chain(presenceStage(context));\n    if (settings.hissCut) chain(hissCutStage(context));\n\n    if (settings.deEsser) {\n        const stage = deEsserStage(context);\n        node.connect(stage.band);\n        built.push(stage.band, stage.detector);\n        stops.push(stage.stop);\n        chain(stage.shaper);\n    }\n\n    const output = context.createGain();\n    output.gain.value = gain;\n    chain(output);\n\n    if (settings.limiter) chain(limiterStage(context));\n\n    const destination = context.createMediaStreamDestination();\n    node.connect(destination);\n    built.push(destination);\n    const track = destination.stream.getAudioTracks()[0];\n\n    const teardown = (): void => {\n        for (const stop of stops) stop();\n        for (const built_ of built) built_.disconnect();\n    };\n\n    if (!track) {\n        teardown();\n        return passThrough(source, ownsSource);\n    }\n\n    return {\n        track,\n        release: (): void => {\n            teardown();\n            track.stop();\n            if (ownsSource) source.stop();\n        },\n        supported: true,\n    };\n}\n\n/** A running monitor. */\nexport interface VoiceMonitor {\n    /** Stop listening and release the graph. Does **not** stop the source track. */\n    stop: () => void;\n}\n\n/**\n * Play your own processed microphone back to you.\n *\n * The only way to hear what these filters do without a second person on the call.\n * Every number the chain is tuned by — how deep the de-esser cuts, where the gate\n * sits, how much presence is too much — is a judgement made by ear, and without\n * this the only available ear belongs to somebody else.\n *\n * **Feedback is the hazard, and the reason this is a held, explicit action rather\n * than a setting:** without headphones the speakers feed the microphone that feeds\n * the speakers. Say so in the UI that offers it.\n *\n * @param source - The raw captured track to listen to. It is never stopped here.\n * @param settings - The stages to hear it through. Default {@link DEFAULT_VOICE_CHAIN}.\n * @param options - See {@link VoiceChainOptions}; `ownsSource` is forced to `false`.\n * @returns A handle that stops the monitor.\n */\nexport function monitorVoiceChain(\n    source: MediaStreamTrack,\n    settings: VoiceChainSettings = DEFAULT_VOICE_CHAIN,\n    options: Omit<VoiceChainOptions, \"ownsSource\"> = {},\n): VoiceMonitor {\n    const Ctor = audioContextConstructor();\n    const context = options.context ?? (Ctor ? new Ctor() : null);\n    if (!context) return { stop: () => undefined };\n\n    const chain = createVoiceChain(source, settings, { ...options, context, ownsSource: false });\n    const playback = context.createMediaStreamSource(\n        typeof MediaStream === \"undefined\"\n            ? (chain.track as unknown as MediaStream)\n            : new MediaStream([chain.track]),\n    );\n    playback.connect(context.destination);\n\n    return {\n        stop: (): void => {\n            playback.disconnect();\n            chain.release();\n        },\n    };\n}\n"],"mappings":"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"}