{"version":3,"file":"level-meter.cjs","names":[],"sources":["../../src/audio/level-meter.ts"],"sourcesContent":["/** Options for {@link createLevelMeter}. */\nexport interface LevelMeterOptions {\n    /**\n     * FFT window. 1024 is a good default: ~21 ms at 48 kHz, long enough to be stable\n     * and short enough to react to a syllable.\n     */\n    fftSize?: number;\n    /**\n     * Extra smoothing on the way down, 0–1. Default `0.7`.\n     *\n     * A raw RMS reading is jittery enough to make a meter look broken. Attack is left\n     * instant — a meter that lags the voice going *up* reads as \"not recording\" —\n     * while the decay is eased, which is how every hardware meter behaves.\n     *\n     * `0` turns the smoothing off and reports the window's RMS as measured, which is\n     * what a detector wants: {@link monitorVoiceActivity} applies its own release,\n     * and two of them stacked would hold the indicator open long past the speech.\n     */\n    decay?: number;\n    /**\n     * Reuse an existing `AudioContext` instead of creating one.\n     *\n     * Browsers cap live contexts (Chrome allows around six), and a meter per remote\n     * participant reaches that in a five-person call. A caller measuring several\n     * streams should open one context and hand it to every meter. `stop()` closes\n     * only a context this meter created — an injected one belongs to the caller.\n     */\n    context?: AudioContext;\n}\n\n/** A running level meter. */\nexport interface LevelMeter {\n    /** Latest level, 0–1. Read it on your own frame loop. */\n    level: () => number;\n    /** Stop sampling and release the Web Audio graph. */\n    stop: () => void;\n}\n\n/**\n * Sample the loudness of a live stream, 0–1.\n *\n * This exists because a recorder with no visible level is indistinguishable from a\n * broken one. A muted OS input, a headset whose mic arm is switched off, the wrong\n * device selected — all three produce a perfectly successful recording of silence,\n * and the user only finds out after they finish talking.\n *\n * The value is RMS, not peak: peak reacts to a single sample and flickers, RMS tracks\n * perceived loudness. It is deliberately **not** React state — updating state per\n * frame would re-render the tree 60 times a second. Poll `level()` from a\n * `requestAnimationFrame` loop and write to the DOM, or read it inside an existing\n * animation.\n *\n * The `AudioContext` is created here and closed by `stop()`. Browsers cap the number\n * of live contexts (Chrome allows around six), so a meter left running on unmount\n * eventually breaks every later one on the page.\n *\n * `level()` applies instant attack and eased release: a value above the current one\n * is taken as is, a lower one decays by `decay`. A meter that fell as fast as it rose\n * reads as noise instead of as loudness.\n *\n * @tempest-limits empty-catch — `stop()` disconnects nodes whose context may already\n * be closed (the tab was backgrounded, the stream's track ended, `stop()` raced an\n * unmount), and a disconnect on a dead graph throws. There is nothing to report and\n * nothing to retry: the resource this call would have released is already gone, and\n * the `context.close()` right after it is what actually matters.\n *\n * @param stream - A live audio stream.\n * @param options - See {@link LevelMeterOptions}.\n * @returns A `level()` reader and a `stop()`.\n *\n * @example\n * const meter = createLevelMeter(stream);\n * const tick = () => { bar.style.transform = `scaleX(${meter.level()})`; raf = requestAnimationFrame(tick); };\n */\nexport function createLevelMeter(\n    stream: MediaStream,\n    { fftSize = 1024, decay = 0.7, context: injectedContext }: LevelMeterOptions = {},\n): LevelMeter {\n    const Ctor: typeof AudioContext | undefined =\n        typeof AudioContext !== \"undefined\"\n            ? AudioContext\n            : (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n\n    if (!injectedContext && !Ctor) {\n        return { level: () => 0, stop: () => undefined };\n    }\n\n    const context = injectedContext ?? new (Ctor as typeof AudioContext)();\n    const source = context.createMediaStreamSource(stream);\n    const analyser = context.createAnalyser();\n    analyser.fftSize = fftSize;\n    source.connect(analyser);\n\n    const samples = new Float32Array(analyser.fftSize);\n    let smoothed = 0;\n    let closed = false;\n\n    const read = (): number => {\n        if (closed) return 0;\n        analyser.getFloatTimeDomainData(samples);\n        let sum = 0;\n        for (let index = 0; index < samples.length; index += 1) {\n            sum += samples[index] * samples[index];\n        }\n        const rms = Math.sqrt(sum / samples.length);\n        smoothed = rms > smoothed ? rms : smoothed * decay + rms * (1 - decay);\n        return Math.min(1, smoothed);\n    };\n\n    return {\n        level: read,\n        stop: (): void => {\n            if (closed) return;\n            closed = true;\n            try {\n                source.disconnect();\n                analyser.disconnect();\n            } catch {\n                /* empty */\n            }\n            if (!injectedContext) void context.close().catch(() => undefined);\n        },\n    };\n}\n"],"mappings":"AA0EA,SAAgB,EACZ,EACA,CAAE,UAAU,KAAM,QAAQ,GAAK,QAAS,GAAuC,CAAC,EACtE,CACV,IAAM,EACF,OAAO,aAAiB,IAClB,aACC,WAA4D,mBAEvE,GAAI,CAAC,GAAmB,CAAC,EACrB,MAAO,CAAE,UAAa,EAAG,SAAY,IAAA,EAAU,EAGnD,IAAM,EAAU,GAAmB,IAAK,EAClC,EAAS,EAAQ,wBAAwB,CAAM,EAC/C,EAAW,EAAQ,eAAe,EACxC,EAAS,QAAU,EACnB,EAAO,QAAQ,CAAQ,EAEvB,IAAM,EAAU,IAAI,aAAa,EAAS,OAAO,EAC7C,EAAW,EACX,EAAS,GAcb,MAAO,CACH,UAbuB,CACvB,GAAI,EAAQ,MAAO,GACnB,EAAS,uBAAuB,CAAO,EACvC,IAAI,EAAM,EACV,IAAK,IAAI,EAAQ,EAAG,EAAQ,EAAQ,OAAQ,GAAS,EACjD,GAAO,EAAQ,GAAS,EAAQ,GAEpC,IAAM,EAAM,KAAK,KAAK,EAAM,EAAQ,MAAM,EAE1C,MADA,GAAW,EAAM,EAAW,EAAM,EAAW,EAAQ,GAAO,EAAI,GACzD,KAAK,IAAI,EAAG,CAAQ,CAC/B,EAII,SAAkB,CACV,MACJ,GAAS,GACT,GAAI,CACA,EAAO,WAAW,EAClB,EAAS,WAAW,CACxB,MAAQ,CAER,CACK,GAAiB,EAAa,MAAM,CAAC,CAAC,UAAY,IAAA,EAAS,CAPvD,CAQb,CACJ,CACJ"}