{"version":3,"file":"voice-chain-stages.cjs","names":[],"sources":["../../src/audio/voice-chain-stages.ts"],"sourcesContent":["/**\n * The individual stages a voice chain is built from, and the two side-chains\n * Web Audio cannot express declaratively.\n *\n * Split out of `voice-chain.ts` because every number here is a tuning decision\n * with a reason attached, and the file that assembles them should read as the\n * signal path rather than as a wall of constants.\n */\n\n/** Everything below this is rumble, not speech. */\nconst HIGH_PASS_HZ = 85;\n\n/** Where consonants live — the band that decides whether a word is understood. */\nconst PRESENCE_HZ = 3000;\nconst PRESENCE_GAIN_DB = 3;\nconst PRESENCE_Q = 1;\n\n/** Where microphone hiss lives, above anything speech needs. */\nconst HISS_HZ = 9000;\n\n/** The sibilance band a de-esser rides. */\nconst SIBILANCE_HZ = 7000;\nconst SIBILANCE_Q = 2;\n\n/** Deepest cut the de-esser applies, in dB. */\nconst DEESSER_MAX_CUT_DB = 10;\n\n/** Band energy above which the cut starts, as RMS. */\nconst DEESSER_THRESHOLD = 0.02;\n\n/** How steeply the cut follows the band's energy above the threshold. */\nconst DEESSER_SLOPE_DB_PER_RMS = 200;\n\n/** Seconds the de-esser takes to reach a new cut. */\nconst DEESSER_SMOOTHING = 0.02;\n\n/**\n * A leveller, not a limiter: 3:1 with a soft knee evens out the distance between\n * a whisper and a shout without audibly pumping.\n */\nconst COMPRESSOR = {\n    threshold: -24,\n    knee: 30,\n    ratio: 3,\n    attack: 0.003,\n    release: 0.25,\n} as const;\n\n/**\n * The ceiling the limiter holds, in dB relative to full scale.\n *\n * Just under zero, because digital clipping is not a degradation the other end\n * can recover from — it arrives as squared-off samples that no amount of\n * processing undoes.\n */\nconst LIMITER = {\n    threshold: -1,\n    knee: 0,\n    ratio: 20,\n    attack: 0.001,\n    release: 0.1,\n} as const;\n\n/** How often the gate and the de-esser look at the signal, in milliseconds. */\nexport const DETECTOR_POLL_MS = 20;\n\n/** Time constant for opening the gate. Short, so a first syllable survives. */\nconst GATE_ATTACK = 0.005;\n\n/** Time constant for closing it. Long, so a word does not end abruptly. */\nconst GATE_RELEASE = 0.12;\n\n/**\n * How long the gate stays open after the level drops.\n *\n * RMS falls to nothing between syllables, so a gate without hold chops speech\n * into pieces — it closes inside a word and reopens on the next one. This is the\n * difference between a gate that works and one everybody turns off again.\n */\nexport const GATE_HOLD_MS = 220;\n\n/** The window each detector reads. */\nconst DETECTOR_FFT_SIZE = 1024;\n\n/**\n * Root-mean-square of a time-domain window.\n *\n * RMS rather than peak because both detectors here answer \"how much energy is\n * there\", not \"did one sample spike\": a peak reading opens a gate on a keyboard\n * click and drives a de-esser off a single transient.\n *\n * @param buffer - Time-domain samples, -1..1.\n * @returns The window's RMS, 0..1.\n */\nfunction rms(buffer: Float32Array): number {\n    let sum = 0;\n    for (const sample of buffer) sum += sample * sample;\n    return Math.sqrt(sum / buffer.length);\n}\n\n/** Build the analyser both detectors read through. */\nfunction detectorFor(context: AudioContext): AnalyserNode {\n    const analyser = context.createAnalyser();\n    analyser.fftSize = DETECTOR_FFT_SIZE;\n    return analyser;\n}\n\n/** A high-pass at 85 Hz: fans, traffic, desk knocks, plosives. */\nexport function highPassStage(context: AudioContext): BiquadFilterNode {\n    const filter = context.createBiquadFilter();\n    filter.type = \"highpass\";\n    filter.frequency.value = HIGH_PASS_HZ;\n    return filter;\n}\n\n/** A 3:1 leveller with a soft knee. */\nexport function compressorStage(context: AudioContext): DynamicsCompressorNode {\n    const comp = context.createDynamicsCompressor();\n    comp.threshold.value = COMPRESSOR.threshold;\n    comp.knee.value = COMPRESSOR.knee;\n    comp.ratio.value = COMPRESSOR.ratio;\n    comp.attack.value = COMPRESSOR.attack;\n    comp.release.value = COMPRESSOR.release;\n    return comp;\n}\n\n/** A +3 dB peak at 3 kHz: speech that reads without getting louder. */\nexport function presenceStage(context: AudioContext): BiquadFilterNode {\n    const eq = context.createBiquadFilter();\n    eq.type = \"peaking\";\n    eq.frequency.value = PRESENCE_HZ;\n    eq.Q.value = PRESENCE_Q;\n    eq.gain.value = PRESENCE_GAIN_DB;\n    return eq;\n}\n\n/** A low-pass at 9 kHz: the hiss of a cheap microphone. */\nexport function hissCutStage(context: AudioContext): BiquadFilterNode {\n    const filter = context.createBiquadFilter();\n    filter.type = \"lowpass\";\n    filter.frequency.value = HISS_HZ;\n    return filter;\n}\n\n/** A hard 20:1 ceiling at -1 dBFS, placed after the output gain. */\nexport function limiterStage(context: AudioContext): DynamicsCompressorNode {\n    const limiter = context.createDynamicsCompressor();\n    limiter.threshold.value = LIMITER.threshold;\n    limiter.knee.value = LIMITER.knee;\n    limiter.ratio.value = LIMITER.ratio;\n    limiter.attack.value = LIMITER.attack;\n    limiter.release.value = LIMITER.release;\n    return limiter;\n}\n\n/** The nodes a gate stage owns. */\nexport interface GateStage {\n    /** Reads the signal entering the gate. */\n    detector: AnalyserNode;\n    /** The gain the detector drives, 0 when closed. */\n    gain: GainNode;\n    /** Stop the detector's timer. */\n    stop: () => void;\n}\n\n/**\n * Silence the line between phrases, without cutting inside a word.\n *\n * Measured on a timer rather than in an `AudioWorklet`. A worklet would gate\n * sample-accurately at the cost of a separate module file the consumer has to\n * serve and a second graph to keep in sync; at 20 ms the difference is inaudible\n * for speech, because the hold and the release are an order of magnitude longer\n * than the polling interval and are what actually shape how a gate sounds.\n *\n * The gate starts closed and opens on the first window over the threshold, so a\n * chain built while the room is quiet does not leak the first 20 ms of it.\n *\n * @param context - The graph's context.\n * @param threshold - Read on every tick, so the caller can move a slider while\n *     the chain is live rather than rebuild it.\n * @returns The stage's nodes and its teardown.\n */\nexport function gateStage(context: AudioContext, threshold: () => number): GateStage {\n    const detector = detectorFor(context);\n    const gain = context.createGain();\n    gain.gain.value = 0;\n\n    const buffer = new Float32Array(detector.fftSize);\n    let lastLoudAt = 0;\n\n    const timer = setInterval(() => {\n        detector.getFloatTimeDomainData(buffer);\n        const now = performance.now();\n        if (rms(buffer) >= threshold()) lastLoudAt = now;\n        const open = lastLoudAt > 0 && now - lastLoudAt < GATE_HOLD_MS;\n        gain.gain.setTargetAtTime(\n            open ? 1 : 0,\n            context.currentTime,\n            open ? GATE_ATTACK : GATE_RELEASE,\n        );\n    }, DETECTOR_POLL_MS);\n\n    return { detector, gain, stop: () => clearInterval(timer) };\n}\n\n/** The nodes a de-esser stage owns. */\nexport interface DeEsserStage {\n    /** Band-pass feeding the detector — the side-chain's input. */\n    band: BiquadFilterNode;\n    /** Reads 6-8 kHz only. */\n    detector: AnalyserNode;\n    /** The peaking filter in the signal path, whose gain rides the band. */\n    shaper: BiquadFilterNode;\n    /** Stop the detector's timer. */\n    stop: () => void;\n}\n\n/**\n * Pull the sibilance band down while it is loud, and let go when it is not.\n *\n * Web Audio has no side-chain, so the detector is built by hand: a band-pass\n * feeds an analyser watching only 6-8 kHz, and that analyser's energy drives the\n * gain of a peaking filter sitting in the main path. The distinction is the whole\n * stage — the cut has to answer to the *band*, not to how loud the person is. A\n * static cut at the same frequency would dull every consonant instead of the\n * syllables that actually hiss.\n *\n * @param context - The graph's context.\n * @returns The stage's nodes and its teardown.\n */\nexport function deEsserStage(context: AudioContext): DeEsserStage {\n    const band = context.createBiquadFilter();\n    band.type = \"bandpass\";\n    band.frequency.value = SIBILANCE_HZ;\n    band.Q.value = SIBILANCE_Q;\n\n    const detector = detectorFor(context);\n    band.connect(detector);\n\n    const shaper = context.createBiquadFilter();\n    shaper.type = \"peaking\";\n    shaper.frequency.value = SIBILANCE_HZ;\n    shaper.Q.value = SIBILANCE_Q;\n    shaper.gain.value = 0;\n\n    const buffer = new Float32Array(detector.fftSize);\n    const timer = setInterval(() => {\n        detector.getFloatTimeDomainData(buffer);\n        const over = Math.max(0, rms(buffer) - DEESSER_THRESHOLD);\n        const cut = Math.min(DEESSER_MAX_CUT_DB, over * DEESSER_SLOPE_DB_PER_RMS);\n        shaper.gain.setTargetAtTime(-cut, context.currentTime, DEESSER_SMOOTHING);\n    }, DETECTOR_POLL_MS);\n\n    return { band, detector, shaper, stop: () => clearInterval(timer) };\n}\n\nexport { rms as windowRms };\n"],"mappings":"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"}