Skip to content
Coastal Healthcare Advocates

Source: sound.js

/**
 * @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