/**
* @file A soft, synthesised click sound when a button is pressed.
*
* ## How the sound is made
*
* Generated live with the Web Audio API, so there is no audio file to
* download. Each click layers two short sounds, like tapping a wooden key:
*
* - **Tick** — a very short burst of white noise through a band-pass filter
* (the crisp transient at the start).
* - **Body** — a quick sine tone whose pitch falls slightly (the soft "tock").
*
* The pitch and filter frequency drift by a few percent on every press, so
* repeated clicks feel played rather than looped.
*
* ## When it plays
*
* On the `click` event, so mouse, touch and keyboard (Enter / Space) presses
* all make a sound. It covers `<button>`, `.btn` links, `[role="button"]`,
* submit/button `<input>`s and `<summary>` (see {@link TARGETS}).
*
* It stays silent for:
* - disabled controls (`disabled` or `aria-disabled="true"`);
* - clicks fired by script (`element.click()`), which aren't "trusted";
* - visitors who prefer reduced motion, or who turned Animations off in
* Site preferences (`data-motion="off"` on `<html>`);
* - anyone with `localStorage.sound === 'off'` (the hook for a future on/off
* control).
*
* Browsers only allow audio to start after a user gesture, so the
* `AudioContext` is created on the first press. It's created/resumed during
* the press (queued from `pointerdown`, for just after the pressed state has
* painted) so the context is already running by the time `click` fires on
* release — otherwise the very first click would sound late.
*
* ## Loading
*
* A classic `<script defer>` in `<head>`, beside `email.js`. ES5 inside an
* IIFE so nothing leaks globally.
*
* @module sound
*/
(function () { // start a private scope that runs immediately
'use strict'; // opt into strict-mode JavaScript
/**
* The Web Audio constructor (with the old Safari prefix as a fallback).
* @type {(function(new:AudioContext)|undefined)}
*/
var AC = window.AudioContext || window.webkitAudioContext; // pick whichever audio API exists
// No Web Audio support: do nothing at all.
if (!AC) return;
/**
* CSS selector for the elements that make a sound when clicked.
* @type {string}
*/
var TARGETS = 'button, .btn, [role="button"], input[type="submit"], input[type="button"], summary'; // clickable controls
/**
* Master output gain (0–1).
* @type {number}
*/
var VOLUME = 0.9; // peaks around -22 dBFS: present on laptop and phone speakers, never loud
/**
* Live media query for the visitor's reduced-motion preference.
* @type {MediaQueryList}
*/
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)'); // watch the reduced-motion setting
/**
* The shared audio context, created lazily by {@link setup}.
* @type {AudioContext|null}
*/
var ctx = null; // no audio context yet
/**
* Master gain node every voice connects to (→ compressor → speakers).
* @type {GainNode|null}
*/
var out = null; // no output node yet
/**
* Reusable 60 ms mono buffer of white noise for the "tick".
* @type {AudioBuffer|null}
*/
var noise = null; // no noise buffer yet
/**
* `performance.now()` timestamp of the last click that played a sound,
* used to debounce double-triggers.
* @type {number}
*/
var lastAt = 0; // no sound played yet
/**
* Whether sound should be suppressed right now.
*
* @returns {boolean} `true` if the visitor prefers reduced motion or has
* turned sound off via `localStorage.sound = 'off'`.
*/
function muted() { // define muted()
if (reduce.matches) return true; // reduced motion requested: stay silent
if (document.documentElement.getAttribute('data-motion') === 'off') return true; // Animations off in Site preferences: same
// Otherwise silent only if sound was switched off (storage errors → not muted).
try { return localStorage.getItem('sound') === 'off'; } catch (e) { return false; }
}
/**
* Finds the clickable control an event came from, if it's one that should
* make a sound.
*
* Uses `closest()` so a click on an icon or `<span>` inside a button still
* counts as a click on the button.
*
* @param {Event} e A `pointerdown` or `click` event.
* @returns {Element|null} The matching, enabled control, or `null`.
*/
function target(e) { // define target(e)
// Guard: the target may be a text node or lack closest() in old browsers.
var el = e.target && e.target.closest ? e.target.closest(TARGETS) : null;
if (!el || el.disabled || el.getAttribute('aria-disabled') === 'true') return null; // none, or disabled
return el; // an enabled control
}
/**
* Creates the audio context and the shared audio graph the first time it's
* needed; afterwards just returns the existing context.
*
* Graph: `voice nodes → out (gain) → compressor → speakers`.
*
* @returns {AudioContext|null} The context, or `null` if the browser refused
* to create one.
*/
function setup() { // define setup()
if (ctx) return ctx; // already set up: reuse it
try { ctx = new AC(); } catch (e) { return null; } // create the context, or give up
// A gentle compressor keeps a flurry of presses from stacking into a clip.
var comp = ctx.createDynamicsCompressor();
comp.threshold.value = -12; // dB level where compression starts
comp.knee.value = 6; // dB range over which it eases in
comp.ratio.value = 8; // 8:1 reduction above the threshold
comp.attack.value = 0.002; // seconds to react
comp.release.value = 0.08; // seconds to let go
out = ctx.createGain(); // create the master volume node
out.gain.value = VOLUME; // set the master volume
// connect() returns its destination, so this chains out → comp → speakers.
out.connect(comp).connect(ctx.destination);
// One reusable 60ms buffer of white noise for the transient.
var len = Math.floor(ctx.sampleRate * 0.06); // number of samples in 60 ms
noise = ctx.createBuffer(1, len, ctx.sampleRate); // create a one-channel buffer that long
var data = noise.getChannelData(0); // get its sample array
// Random samples in the range -1…1.
for (var i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
return ctx; // hand back the ready context
}
/**
* Schedules a percussive attack/decay envelope on an audio parameter.
*
* Exponential ramps can't start from or reach exactly 0, so 0.0001
* (effectively silent) is used as the floor.
*
* @param {AudioParam} param The parameter to shape (usually a gain).
* @param {number} t Start time, in the context's clock (seconds).
* @param {number} peak Value to rise to.
* @param {number} attack Seconds to rise from silence to `peak`.
* @param {number} decay Seconds to fall from `peak` back to silence.
* @returns {void}
*/
function envelope(param, t, peak, attack, decay) { // define envelope(param, t, peak, attack, decay)
param.setValueAtTime(0.0001, t); // start silent
param.exponentialRampToValueAtTime(peak, t + attack); // rise to the peak
param.exponentialRampToValueAtTime(0.0001, t + attack + decay); // fall back to silence
}
/**
* Plays one click: a filtered-noise "tick" layered over a falling sine
* "body". All nodes are one-shot and are discarded after they stop.
*
* @param {AudioContext} c A running audio context.
* @returns {void}
*/
function voice(c) { // define voice(c)
// Start 2 ms ahead so the scheduled ramps are never already in the past.
var t = c.currentTime + 0.002;
// Random ±4% detune shared by both layers so each press sounds slightly different.
var drift = 0.96 + Math.random() * 0.08;
// --- Tick: band-passed noise, ~23 ms long ------------------------------
var tick = c.createBufferSource(); // a player for the noise buffer
tick.buffer = noise; // give it the noise
var band = c.createBiquadFilter(); // a filter to shape the noise
band.type = 'bandpass'; // keep only a band of frequencies
band.frequency.value = 2300 * drift; // center frequency in Hz
band.Q.value = 1.3; // fairly wide band
var tickGain = c.createGain(); // a volume node for the tick
envelope(tickGain.gain, t, 0.5, 0.001, 0.022); // 1 ms attack, 22 ms decay
tick.connect(band).connect(tickGain).connect(out); // noise → filter → volume → master
tick.start(t); // start the tick
tick.stop(t + 0.05); // stop it after 50 ms
// --- Body: sine gliding 540 Hz → 360 Hz over 45 ms ---------------------
var body = c.createOscillator(); // a tone generator
body.type = 'sine'; // pure, soft tone
body.frequency.setValueAtTime(540 * drift, t); // start pitch
body.frequency.exponentialRampToValueAtTime(360 * drift, t + 0.045); // glide down over 45 ms
var bodyGain = c.createGain(); // a volume node for the body
envelope(bodyGain.gain, t, 0.16, 0.002, 0.05); // 2 ms attack, 50 ms decay
body.connect(bodyGain).connect(out); // tone → volume → master
body.start(t); // start the tone
body.stop(t + 0.08); // stop it after 80 ms
}
/**
* Plays a click, first resuming the audio context if the browser has it
* suspended. Failures (e.g. autoplay policy) are ignored silently.
*
* @returns {void}
*/
function play() { // define play()
var c = setup(); // get (or create) the audio context
if (!c) return; // no audio available
if (c.state === 'running') { // audio is already allowed:
voice(c); // play straight away
} else { // audio is suspended:
c.resume().then(function () { // ask the browser to resume it, then
// Only play if resuming actually worked.
if (c.state === 'running') voice(c);
}, function () {}); // ignore a refused resume
}
}
/**
* Whether a deferred {@link setup} is already queued (see {@link warm}).
* @type {boolean}
*/
var warming = false; // nothing queued yet
/**
* Gets the context ready during a press, without holding the press up.
*
* The first `new AudioContext()` in a browser session is synchronous and
* slow: it waits on the audio device, not the CPU (200–500 ms in Chromium,
* measured 2026-09-18). Run inside the `pointerdown` handler, that wait sat
* between the visitor's first press and anything on screen answering it.
* So the first build is queued for just after the next paint: the pressed
* state shows at once, and the context is built while the finger is still
* down. If the click gets there first, {@link play} builds it as before.
*
* @returns {void}
*/
function warm() { // define warm()
if (ctx) { // already built:
if (ctx.state !== 'running') ctx.resume().catch(function () {}); // start it running; ignore failure
return; // nothing to build
}
if (warming) return; // a build is already queued
warming = true; // mark one as queued
requestAnimationFrame(function () { // once this press's frame is being drawn…
setTimeout(function () { // …and has been painted:
warming = false; // the queued build is now running
var c = setup(); // create the context (or reuse the one a fast click made)
if (c && c.state !== 'running') c.resume().catch(function () {}); // start it running; ignore failure
}, 0); // next task after the paint
});
}
// Warm the context during the press so the click lands on release.
// Capture phase (`true`) so it runs before any other handler can stop it.
document.addEventListener('pointerdown', function (e) {
if (muted() || !target(e)) return; // silent or not a button: skip
warm(); // build or resume the context, off this event
}, true); // true = capture phase
// Capture phase: sounds even if a handler stops the click from bubbling.
document.addEventListener('click', function (e) {
// isTrusted is false for clicks triggered from script.
if (!e.isTrusted || muted() || !target(e)) return;
var now = performance.now(); // current high-resolution time
if (now - lastAt < 40) return; // one sound per press, even if two elements match
lastAt = now; // remember when this sound played
play(); // make the click sound
}, true); // true = capture phase
})(); // end of the private scope; run it now