websfx
Version:
Play synthesized UI sound effects using the Web Audio API
645 lines (644 loc) • 13 kB
JavaScript
//#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