{"version":3,"file":"sfx-pool.cjs","names":[],"sources":["../../src/audio/sfx-pool.ts"],"sourcesContent":["/** Options for {@link createSfxPool}. */\nexport interface SfxPoolOptions {\n    /**\n     * Master volume, `0`–`1`, multiplied into every per-play volume. Default\n     * `1`. Wire it to the app's sound setting so one value governs the lot.\n     */\n    volume?: number;\n    /**\n     * Prefix applied to sources that are not absolute URLs — typically Vite's\n     * `import.meta.env.BASE_URL`, so a build served from a subpath resolves.\n     * Default `\"\"`.\n     */\n    baseUrl?: string;\n    /**\n     * Elements kept per source. `1` (the default) restarts the clip on every\n     * play, which is what a menu blip wants. Raise it to let a sound overlap\n     * itself — a hit landing while the previous one is still ringing.\n     */\n    voices?: number;\n    /**\n     * Maximum number of distinct sources held. When exceeded, the least\n     * recently played source is released. Default `48`.\n     */\n    maxSources?: number;\n}\n\n/** Per-play overrides. */\nexport interface PlaySfxOptions {\n    /** Volume for this play, `0`–`1`, multiplied by the pool's master. Default `1`. */\n    volume?: number;\n}\n\n/** Imperative handle over a pool of short sound effects. */\nexport interface SfxPool {\n    /** Play a clip, allocating and caching its element on first use. */\n    play: (src: string, options?: PlaySfxOptions) => void;\n    /** Fetch clips ahead of the first play, so it is not silent while the file downloads. */\n    preload: (src: string | string[]) => void;\n    /** Set the master volume, applying it to anything already sounding. */\n    setVolume: (volume: number) => void;\n    /** Stop one source, or every source when called with no argument. */\n    stop: (src?: string) => void;\n    /** Release every element. Call on unmount. */\n    dispose: () => void;\n}\n\n/** Clamp to the range `HTMLMediaElement.volume` accepts, rejecting `NaN`. */\nfunction clampVolume(value: number): number {\n    if (!Number.isFinite(value)) return 1;\n    return Math.min(1, Math.max(0, value));\n}\n\n/**\n * A pool of preallocated `<audio>` elements for short sound effects.\n *\n * `new Audio(src)` on every play allocates an element and re-enters the network\n * stack for a file the browser already has, which is the wrong shape for a\n * sound that fires dozens of times a minute — a UI blip, a hit, a pickup. The\n * pool allocates once per source and replays.\n *\n * This is deliberately not {@link createAudioPlayer}: that handle tracks a\n * single \"current\" clip with loop, sink routing and lifecycle callbacks, which\n * is what background music needs. Effects are the opposite case — many\n * sources, all short, fire-and-forget, and the only thing that matters is that\n * firing one is cheap.\n *\n * A blocked `play()` is swallowed. Browsers reject playback until the user has\n * interacted with the page, and a sound effect is by definition not worth\n * interrupting anything over; call {@link SfxPool.preload} after the first\n * interaction if you want the pool warm.\n *\n * @param options - Master volume, base URL, voices per source and pool size.\n * @returns The pool handle.\n *\n * @example\n * const sfx = createSfxPool({ volume: 0.6, baseUrl: import.meta.env.BASE_URL });\n * sfx.preload([\"sfx/select.mp3\", \"sfx/back.mp3\"]);\n *\n * <button onClick={() => sfx.play(\"sfx/select.mp3\")}>Confirmar</button>\n */\n/** One source's voices plus the round-robin cursor over them. */\ninterface SourceEntry {\n    /** The audio elements sharing this source. */\n    elements: HTMLAudioElement[];\n    /** Index of the element the next play claims. */\n    next: number;\n}\n\n/**\n * Resolve a source against the pool's base URL.\n *\n * An absolute URL, a protocol-relative one and a `data:` URI are already\n * addresses — prefixing them would produce a path that resolves to nothing.\n *\n * @param baseUrl - Pool base, possibly empty.\n * @param src - Source as the caller wrote it.\n * @returns The URL to load.\n */\nfunction resolveSource(baseUrl: string, src: string): string {\n    if (!baseUrl || /^(https?:)?\\/\\//.test(src) || src.startsWith(\"data:\")) return src;\n    return `${baseUrl.replace(/\\/$/, \"\")}/${src.replace(/^\\//, \"\")}`;\n}\n\n/**\n * Stop every voice of an entry and let the browser drop its buffer.\n *\n * Removing `src` before `load()` is what frees the decoded audio: `load()` on an\n * element that still has a source re-fetches it instead.\n *\n * @param entry - The entry being evicted.\n */\nfunction releaseEntry(entry: { elements: HTMLAudioElement[] }): void {\n    for (const element of entry.elements) {\n        element.pause();\n        element.removeAttribute(\"src\");\n        element.load();\n    }\n}\n\n/**\n * Drop the least recently played source once the pool is over its cap.\n *\n * The map is insertion-ordered by last play, so the first key is the coldest.\n * The source that was just admitted is never the one evicted, which would\n * otherwise happen with a cap of one.\n *\n * @param sources - The live pool, mutated in place.\n * @param keepUrl - The source that must survive this eviction.\n * @param maxSources - How many sources the pool may hold.\n */\nfunction evictLeastRecent(\n    sources: Map<string, SourceEntry>,\n    keepUrl: string,\n    maxSources: number,\n): void {\n    if (sources.size <= maxSources) return;\n    const oldest = sources.keys().next();\n    if (oldest.done || oldest.value === keepUrl) return;\n    const evicted = sources.get(oldest.value);\n    if (evicted) releaseEntry(evicted);\n    sources.delete(oldest.value);\n}\n\nexport function createSfxPool(options: SfxPoolOptions = {}): SfxPool {\n    const { baseUrl = \"\", voices = 1, maxSources = 48 } = options;\n\n    let master = clampVolume(options.volume ?? 1);\n    const voiceCount = Math.max(1, Math.floor(voices));\n\n    /**\n     * Insertion-ordered by last play, so evicting the first key drops the\n     * least recently used source.\n     */\n    const sources = new Map<string, SourceEntry>();\n\n    /**\n     * Per-play gain of whatever each element last played at.\n     *\n     * `setVolume` has to rescale live playback by the same factor it was\n     * started with; assigning the master directly would yank a clip that\n     * started at half volume up to full.\n     */\n    const gains = new WeakMap<HTMLAudioElement, number>();\n\n    /**\n     * The pool entry for a source, creating and admitting it when it is new.\n     *\n     * Re-inserts an existing entry so the map order stays \"least recently used\n     * first\", which is what makes evicting the first key correct.\n     *\n     * `created` is reported because assigning `src` on an element whose `preload`\n     * is `\"auto\"` already starts the fetch: calling `load()` on an entry that was\n     * merely looked up would abort that fetch and discard whatever is already\n     * buffered, per the media element load algorithm.\n     *\n     * @param src - Raw source, resolved against `baseUrl`.\n     * @returns The entry and whether this call created it, or `null` where\n     *   `Audio` does not exist.\n     */\n    function acquire(src: string): { entry: SourceEntry; created: boolean } | null {\n        if (typeof Audio === \"undefined\") return null;\n\n        const url = resolveSource(baseUrl, src);\n        const existing = sources.get(url);\n        if (existing) {\n            sources.delete(url);\n            sources.set(url, existing);\n            return { entry: existing, created: false };\n        }\n\n        const entry: SourceEntry = {\n            elements: Array.from({ length: voiceCount }, () => {\n                const element = new Audio(url);\n                element.preload = \"auto\";\n                return element;\n            }),\n            next: 0,\n        };\n        sources.set(url, entry);\n\n        evictLeastRecent(sources, url, maxSources);\n\n        return { entry, created: true };\n    }\n\n    return {\n        play(src, playOptions = {}) {\n            const acquired = acquire(src);\n            if (!acquired) return;\n\n            const entry = acquired.entry;\n            const element = entry.elements[entry.next];\n            entry.next = (entry.next + 1) % entry.elements.length;\n\n            const gain = clampVolume(playOptions.volume ?? 1);\n            gains.set(element, gain);\n\n            element.pause();\n            element.currentTime = 0;\n            element.volume = clampVolume(gain * master);\n            void element.play().catch(() => {});\n        },\n\n        preload(src) {\n            for (const one of Array.isArray(src) ? src : [src]) {\n                const acquired = acquire(one);\n                if (!acquired?.created) continue;\n                for (const element of acquired.entry.elements) element.load();\n            }\n        },\n\n        setVolume(volume) {\n            master = clampVolume(volume);\n            for (const entry of sources.values()) {\n                for (const element of entry.elements) {\n                    if (element.paused) continue;\n                    element.volume = clampVolume((gains.get(element) ?? 1) * master);\n                }\n            }\n        },\n\n        stop(src) {\n            const targets = src\n                ? [sources.get(resolveSource(baseUrl, src))]\n                : [...sources.values()];\n            for (const entry of targets) {\n                if (!entry) continue;\n                for (const element of entry.elements) {\n                    element.pause();\n                    element.currentTime = 0;\n                }\n            }\n        },\n\n        dispose() {\n            for (const entry of sources.values()) releaseEntry(entry);\n            sources.clear();\n        },\n    };\n}\n"],"mappings":"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"}