UNPKG

websfx

Version:

Play synthesized UI sound effects using the Web Audio API

645 lines (644 loc) • 13 kB
//#region src/utils.ts /** * Clamps a number to the inclusive range 0-1. * * @param value - The number to clamp. * @returns The clamped value. */ function clamp(value) { return Math.min(1, Math.max(0, value)); } //#endregion //#region src/context.ts let audio; let config = { volume: 1 }; /** * Returns the shared audio graph, creating the `AudioContext` lazily on * first use. * * @returns The audio graph, or `undefined` when Web Audio is unavailable * (for example during server-side rendering) or when the master volume * is `0`. */ function getAudio() { if (config.volume <= 0) return void 0; const io = ensureAudio(); if (io?.context.state === "suspended") io.context.resume().catch(() => void 0); return io; } /** * Reads or updates the global sound configuration. * * Called with a partial configuration it applies the update first. Always * returns the current configuration. Set `volume` to `0` to mute. * * @example * ```ts * configure({ volume: 0.5 }); * configure({ volume: 0 }); // mute * const { volume } = configure(); * ``` * * @param options - Partial configuration to merge. * @returns The current configuration. */ function configure(options) { if (options?.volume !== void 0) { config = { volume: clamp(options.volume) }; if (audio) audio.master.gain.value = config.volume; } return { ...config }; } /** * Creates the `AudioContext` and master gain node on first use. * * @returns The audio graph, or `undefined` when Web Audio is unavailable. */ function ensureAudio() { if (audio) return audio; if (typeof AudioContext === "undefined") return void 0; let context; try { context = new AudioContext(); } catch { return; } const master = context.createGain(); master.gain.value = config.volume; master.connect(context.destination); audio = { context, master }; return audio; } //#endregion //#region src/engine.ts /** * Floor for exponential gain ramps; `0` is not a valid ramp target. */ const FLOOR = 1e-4; /** * Plays a single oscillator tone through the master gain. * * Does nothing when Web Audio is unavailable, or when the requested * volume, duration, or frequency is not positive. * * @param options - Tone options. */ function tone(options) { const audio = getAudio(); if (!audio) return; const { frequency, endFrequency, duration = .1, delay = 0, attack = .005, type = "sine", volume = 1, pitch = 1 } = options; const peak = clamp(volume); const startFrequency = frequency * pitch; if (duration <= 0 || peak <= 0 || startFrequency <= 0) return; const { context, master } = audio; const start = context.currentTime + delay; const end = start + duration; const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.type = type; oscillator.frequency.setValueAtTime(startFrequency, start); if (endFrequency !== void 0) oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, endFrequency * pitch), end); gain.gain.setValueAtTime(FLOOR, start); gain.gain.exponentialRampToValueAtTime(peak, start + Math.min(attack, duration)); gain.gain.exponentialRampToValueAtTime(FLOOR, end); oscillator.connect(gain); gain.connect(master); oscillator.start(start); oscillator.stop(end); } /** * Plays a filtered burst of white noise through the master gain. * * The noise buffer is generated once per `AudioContext` and reused. * * @param options - Noise options. */ function noise(options = {}) { const audio = getAudio(); if (!audio) return; const { duration = .1, delay = 0, attack = .002, type = "lowpass", frequency = 1500, q, volume = 1, pitch = 1 } = options; const peak = clamp(volume); if (duration <= 0 || peak <= 0) return; const { context, master } = audio; const start = context.currentTime + delay; const end = start + duration; const source = context.createBufferSource(); const filter = context.createBiquadFilter(); const gain = context.createGain(); source.buffer = getNoiseBuffer(context); filter.type = type; filter.frequency.setValueAtTime(Math.max(1, frequency * pitch), start); if (q !== void 0) filter.Q.value = q; gain.gain.setValueAtTime(FLOOR, start); gain.gain.exponentialRampToValueAtTime(peak, start + Math.min(attack, duration)); gain.gain.exponentialRampToValueAtTime(FLOOR, end); source.connect(filter); filter.connect(gain); gain.connect(master); source.start(start); source.stop(end); } /** * Plays a series of tones, one after another. * * @param notes - The notes to play. * @param options - Sequence options applied to every note. */ function sequence(notes, options = {}) { const { gap = .02, type = "sine", ...rest } = options; let delay = 0; for (const note of notes) { const duration = note.duration ?? .08; tone({ frequency: note.frequency, duration, delay, type, ...rest }); delay += duration + gap; } } const noiseBuffers = /* @__PURE__ */ new WeakMap(); /** * Returns a one-second white noise buffer for the given context, * creating and memoizing it on first use. * * @param context - The context to create the buffer for. * @returns The noise buffer. */ function getNoiseBuffer(context) { const cached = noiseBuffers.get(context); if (cached) return cached; const length = Math.floor(context.sampleRate); const buffer = context.createBuffer(1, length, context.sampleRate); const data = buffer.getChannelData(0); for (let index = 0; index < length; index += 1) data[index] = Math.random() * 2 - 1; noiseBuffers.set(context, buffer); return buffer; } //#endregion //#region src/sounds.ts const clickTone = { frequency: 1e3, endFrequency: 700, duration: .05, type: "triangle", volume: .8 }; function playClick(options, delay = 0) { tone({ ...clickTone, ...options, delay }); } /** * Plays a sharp click, e.g. for buttons and links. * * @param options - Per-call sound options. */ function click(options = {}) { playClick(options); } /** * Plays a soft tick for pointer hover states. * * @param options - Per-call sound options. */ function hover(options = {}) { tone({ frequency: 900, duration: .04, type: "sine", volume: .4, ...options }); } /** * Plays a subtle cue for keyboard focus. * * @param options - Per-call sound options. */ function focus(options = {}) { tone({ frequency: 1200, duration: .05, type: "sine", volume: .5, ...options }); } /** * Plays a descending tone for a pointer or key press. * * @param options - Per-call sound options. */ function press(options = {}) { tone({ frequency: 700, endFrequency: 500, duration: .06, type: "triangle", volume: .7, ...options }); } /** * Plays a rising tone for a pointer or key release. * * @param options - Per-call sound options. */ function release(options = {}) { tone({ frequency: 500, endFrequency: 750, duration: .06, type: "triangle", volume: .7, ...options }); } /** * Plays a sustained rising tone for long press gestures. * * @param options - Per-call sound options. */ function longPress(options = {}) { tone({ frequency: 440, endFrequency: 660, duration: .3, attack: .05, type: "sine", volume: .7, ...options }); } /** * Plays two quick clicks for double click gestures. * * @param options - Per-call sound options. */ function doubleClick(options = {}) { playClick(options); playClick(options, .09); } /** * Plays a soft friction tick for drag gestures. * * @param options - Per-call sound options. */ function drag(options = {}) { noise({ duration: .1, type: "bandpass", frequency: 1200, q: 2, volume: .4, ...options }); } /** * Plays a low thud for drop gestures. * * @param options - Per-call sound options. */ function drop(options = {}) { tone({ frequency: 150, endFrequency: 60, duration: .18, type: "sine", volume: .9, ...options }); noise({ duration: .12, type: "lowpass", frequency: 500, volume: .3, ...options }); } /** * Plays a keystroke tap for text input. * * @param options - Per-call sound options. */ function type(options = {}) { noise({ duration: .03, type: "bandpass", frequency: 2500, q: 1, volume: .5, ...options }); } /** * Plays a rising two-note chime for selection. * * @param options - Per-call sound options. */ function select(options = {}) { sequence([{ frequency: 660, duration: .06 }, { frequency: 990, duration: .09 }], { type: "sine", gap: 0, volume: .65, ...options }); } /** * Plays a falling two-note chime for deselection. * * @param options - Per-call sound options. */ function deselect(options = {}) { sequence([{ frequency: 990, duration: .06 }, { frequency: 660, duration: .09 }], { type: "sine", gap: 0, volume: .65, ...options }); } /** * Plays a rising sweep for opening UI such as modals and popovers. * * @param options - Per-call sound options. */ function open(options = {}) { tone({ frequency: 300, endFrequency: 900, duration: .18, type: "triangle", volume: .65, ...options }); } /** * Plays a falling sweep for closing UI such as modals and popovers. * * @param options - Per-call sound options. */ function close(options = {}) { tone({ frequency: 900, endFrequency: 300, duration: .18, type: "triangle", volume: .65, ...options }); } /** * Plays a falling swoosh for navigating back. * * @param options - Per-call sound options. */ function back(options = {}) { tone({ frequency: 700, endFrequency: 350, duration: .15, type: "sine", volume: .65, ...options }); } /** * Plays a rising swoosh for navigating forward. * * @param options - Per-call sound options. */ function forward(options = {}) { tone({ frequency: 350, endFrequency: 700, duration: .15, type: "sine", volume: .65, ...options }); } /** * Plays a plain sine beep. * * @example * ```ts * beep(); * beep({ frequency: 440 }); * ``` * * @param options - Per-call sound options and tone frequency. */ function beep(options = {}) { const { frequency = 880, ...rest } = options; tone({ frequency, duration: .15, type: "sine", volume: .7, ...rest }); } /** * Plays a rising major arpeggio for successful operations. * * @param options - Per-call sound options. */ function success(options = {}) { sequence([ { frequency: 523.25, duration: .08 }, { frequency: 659.25, duration: .08 }, { frequency: 783.99, duration: .08 }, { frequency: 1046.5, duration: .16 } ], { type: "sine", gap: .02, volume: .65, ...options }); } /** * Plays a low two-note buzz for failed operations. * * @param options - Per-call sound options. */ function error(options = {}) { sequence([{ frequency: 220, duration: .12 }, { frequency: 165, duration: .18 }], { type: "square", gap: 0, volume: .45, ...options }); } /** * Plays an alternating two-tone alert for warnings. * * @param options - Per-call sound options. */ function warning(options = {}) { sequence([ { frequency: 880, duration: .08 }, { frequency: 660, duration: .08 }, { frequency: 880, duration: .08 }, { frequency: 660, duration: .08 } ], { type: "square", gap: .04, volume: .4, ...options }); } /** * Plays a gentle descending two-note tone for cancelled operations. * * @param options - Per-call sound options. */ function cancel(options = {}) { sequence([{ frequency: 600, duration: .09 }, { frequency: 450, duration: .14 }], { type: "triangle", gap: .01, volume: .65, ...options }); } /** * Plays a two-note chime for notifications. * * @param options - Per-call sound options. */ function notification(options = {}) { sequence([{ frequency: 880, duration: .1 }, { frequency: 1320, duration: .18 }], { type: "sine", gap: .08, volume: .65, ...options }); } /** * Plays a rising two-note blip for copy operations. * * @param options - Per-call sound options. */ function copy(options = {}) { sequence([{ frequency: 900, duration: .05 }, { frequency: 1350, duration: .07 }], { type: "sine", gap: .03, volume: .6, ...options }); } /** * Plays a soft falling tone for paste operations. * * @param options - Per-call sound options. */ function paste(options = {}) { tone({ frequency: 1100, endFrequency: 800, duration: .09, type: "sine", volume: .65, ...options }); } /** * Plays a descending blip for destructive remove actions. * * @param options - Per-call sound options. */ function remove(options = {}) { tone({ frequency: 500, endFrequency: 250, duration: .12, type: "triangle", volume: .7, ...options }); } /** * Plays a playful rising bloop for reactions. * * @param options - Per-call sound options. */ function reaction(options = {}) { tone({ frequency: 500, endFrequency: 1400, duration: .12, type: "sine", volume: .65, ...options }); } //#endregion export { back, beep, cancel, click, close, configure, copy, deselect, doubleClick, drag, drop, error, focus, forward, hover, longPress, notification, open, paste, press, reaction, release, remove, select, success, type, warning }; //# sourceMappingURL=index.mjs.map