Sound

Short synthesized sounds for buttons and other controls, added with v-sound, with a mute switch and volume.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add sound
pnpm dlx ermineui add sound
yarn dlx ermineui add sound
bunx ermineui add sound

That copies 4 files into src/components/ui/sound/. Files you already have are kept.

Use

import { SoundToggle, vSound, playSound, soundNames, soundSettings, useSound } from '@/components/ui/sound'
<script setup lang="ts">
import { SoundToggle, playSound, soundSettings, vSound } from '@/components/ui/sound'
</script>

<template>
  <Button v-sound>Save</Button>                   <!-- the default click -->
  <Button v-sound="'pop'">Add</Button>            <!-- a named sound -->
  <Switch v-sound.toggle v-model="on" />          <!-- "on" or "off", to match the new state -->
  <Button v-sound.hover>Menu</Button>             <!-- plus a faint tick when the pointer arrives -->
  <SoundToggle />                                 <!-- a mute button for the whole page -->
</template>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
SoundToggle.vuesrc/components/ui/sound/SoundToggle.vue
directive.tssrc/components/ui/sound/directive.ts
index.tssrc/components/ui/sound/index.ts
sound.tssrc/components/ui/sound/sound.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

SoundToggle.vue 79 lines
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { playSound, useSound } from './sound'

withDefaults(
  defineProps<{
    /** Accessible name, and the tooltip. */
    label?: string
  }>(),
  { label: 'Sound' },
)

const { settings } = useSound()
// The saved choice is only known in the browser; until then the button shows the default, so server and browser agree.
const mounted = ref(false)
onMounted(() => {
  mounted.value = true
})

function toggle() {
  settings.enabled = !settings.enabled
  // Turning sound on demonstrates itself.
  if (settings.enabled) playSound('on')
}
</script>

<template>
  <!-- A pressed-state button: screen readers say "Sound, pressed" or "Sound, not pressed". -->
  <button type="button" class="er-sound-toggle" :aria-label="label" :title="label" :aria-pressed="mounted ? settings.enabled : true" @click="toggle">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M4 9v6h4l5 4V5L8 9H4Z" />
      <template v-if="!mounted || settings.enabled">
        <path d="M16.500 9a4 4 0 0 1 0 6" />
        <path d="M19 6.500a8 8 0 0 1 0 11" />
      </template>
      <path v-else d="m17 9.500 5 5m0-5-5 5" />
    </svg>
  </button>
</template>

<style>
@layer components {
  .er-sound-toggle {
    display: inline-grid;
    place-items: center;
    width: var(--er-control-md);
    height: var(--er-control-md);
    padding: 0;
    border: 1px solid var(--er-input);
    border-radius: var(--er-radius-control);
    background: var(--er-input-background);
    color: var(--er-foreground);
    box-shadow: var(--er-shadow-xs);
    cursor: pointer;
    transition:
      background-color var(--er-duration),
      color var(--er-duration);
  }

  .er-sound-toggle:hover {
    background: var(--er-accent);
  }

  .er-sound-toggle[aria-pressed='false'] {
    color: var(--er-muted-foreground);
  }

  .er-sound-toggle:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-sound-toggle svg {
    width: 1rem;
    height: 1rem;
  }
}
</style>
directive.ts 74 lines
import type { Directive } from 'vue'
import { playSound, type SoundName } from './sound'

/**
 * v-sound — makes a control play a sound when it is used. Works on any element or single-root
 * component, without changing the component:
 *
 *   <Button v-sound>Save</Button>                   the default click
 *   <Button v-sound="'pop'">Add</Button>            a named sound
 *   <Switch v-sound.toggle />                       "on" or "off", to match the new state
 *   <Button v-sound.hover>Menu</Button>             also a faint tick when the pointer arrives
 *   <Button v-sound="{ press: 'success', hover: 'tick' }" />
 *   <Button v-sound="false">Quiet</Button>          switched off
 */
type SoundBinding = SoundName | boolean | undefined | { press?: SoundName | false; hover?: SoundName | false }

interface State {
  value: SoundBinding
  toggle: boolean
  hover: boolean
  onClick: () => void
  onEnter: (event: PointerEvent) => void
}

const states = new WeakMap<HTMLElement, State>()

/** Whether a toggling control is now on: a checkbox or switch, or a button that says so with ARIA. */
function isOn(el: HTMLElement): boolean {
  if (el instanceof HTMLInputElement) return el.checked
  return el.getAttribute('aria-pressed') === 'true' || el.getAttribute('aria-checked') === 'true' || el.getAttribute('aria-expanded') === 'true'
}

export const vSound: Directive<HTMLElement, SoundBinding> = {
  mounted(el, binding) {
    const state: State = {
      value: binding.value,
      toggle: !!binding.modifiers.toggle,
      hover: !!binding.modifiers.hover,
      // "click" also fires for Enter and Space, so keyboard users hear the same thing as mouse users.
      onClick: () => {
        const value = state.value
        if (value === false) return
        const press = typeof value === 'object' ? value.press : value
        if (press === false) return
        if (state.toggle) {
          // Checked after the control has updated itself, so the sound matches where it ended up.
          queueMicrotask(() => playSound(isOn(el) ? 'on' : 'off'))
        } else playSound(typeof press === 'string' ? press : 'click')
      },
      onEnter: (event) => {
        const value = state.value
        const hover = typeof value === 'object' && value !== null ? value.hover : state.hover ? 'tick' : false
        // Touch has no hover, and a disabled control should stay silent.
        if (!hover || value === false || event.pointerType === 'touch' || el.matches(':disabled, [aria-disabled="true"]')) return
        playSound(hover)
      },
    }
    states.set(el, state)
    el.addEventListener('click', state.onClick)
    el.addEventListener('pointerenter', state.onEnter)
  },
  updated(el, binding) {
    const state = states.get(el)
    if (state) state.value = binding.value
  },
  unmounted(el) {
    const state = states.get(el)
    if (!state) return
    el.removeEventListener('click', state.onClick)
    el.removeEventListener('pointerenter', state.onEnter)
    states.delete(el)
  },
}
index.ts 5 lines
/** Sound — short synthesized sounds for buttons and other controls, added with v-sound, with a mute switch and volume. */
export { default as SoundToggle } from './SoundToggle.vue'
export { vSound } from './directive'
export { playSound, soundNames, soundSettings, useSound, type SoundName } from './sound'
sound.ts 155 lines
import { effectScope, reactive, watch } from 'vue'

/**
 * Interface sounds made with the Web Audio API. Nothing is downloaded: each sound is a recipe of
 * one or more short tones, played by the browser's own oscillators.
 */

interface Tone {
  /** Pitch in hertz at the start of the tone. */
  freq: number
  /** Pitch to slide to by the end. Leave out for a steady note. */
  to?: number
  /** When the tone starts, in seconds after the sound begins. */
  at?: number
  /** How long it lasts, in seconds. */
  duration: number
  /** Loudness from 0 to 1, before the overall volume is applied. */
  gain?: number
  /** Wave shape: sine is soft and round, triangle a little brighter, square is buzzy. */
  type?: OscillatorType
}

/** The sounds on offer. Edit the recipes, or add your own: every name here works with v-sound and playSound. */
const SOUNDS = {
  /** A crisp, neutral press. The default for buttons. */
  click: [{ freq: 1100, to: 700, duration: 0.045, gain: 0.5 }],
  /** Softer and lower than click, for secondary actions. */
  tap: [{ freq: 520, to: 380, duration: 0.05, gain: 0.45 }],
  /** A rising bubble, for adding or opening something. */
  pop: [{ freq: 320, to: 760, duration: 0.07, gain: 0.5 }],
  /** Barely there. For hover, or stepping through a list. */
  tick: [{ freq: 1800, duration: 0.012, gain: 0.18, type: 'triangle' }],
  /** Two rising notes: something was switched on. */
  on: [
    { freq: 520, duration: 0.06, gain: 0.4 },
    { freq: 780, at: 0.055, duration: 0.08, gain: 0.4 },
  ],
  /** Two falling notes: something was switched off. */
  off: [
    { freq: 780, duration: 0.06, gain: 0.4 },
    { freq: 520, at: 0.055, duration: 0.08, gain: 0.4 },
  ],
  /** A short rising chord: it worked. */
  success: [
    { freq: 523.25, duration: 0.09, gain: 0.35 },
    { freq: 659.25, at: 0.07, duration: 0.09, gain: 0.35 },
    { freq: 783.99, at: 0.14, duration: 0.16, gain: 0.35 },
  ],
  /** Two low, flat notes: it did not work. */
  error: [
    { freq: 220, duration: 0.11, gain: 0.4, type: 'triangle' },
    { freq: 174.61, at: 0.1, duration: 0.18, gain: 0.4, type: 'triangle' },
  ],
  /** A bright two-note chime, for something arriving. */
  notify: [
    { freq: 880, duration: 0.09, gain: 0.35 },
    { freq: 1174.66, at: 0.08, duration: 0.2, gain: 0.3 },
  ],
} satisfies Record<string, Tone[]>

export type SoundName = keyof typeof SOUNDS
export const soundNames = Object.keys(SOUNDS) as SoundName[]

const STORAGE_KEY = 'er-sound'

/**
 * Whether sounds play, and how loud. Shared by the whole page and remembered between visits.
 * Bind a switch or slider straight to it, or use <SoundToggle />.
 */
export const soundSettings = reactive({ enabled: true, volume: 0.5 })

let ready = false
/** Reads the saved choice the first time it is needed — in the browser only, never on a server. */
function prepare() {
  if (ready || typeof window === 'undefined') return
  ready = true
  try {
    const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null') as { enabled?: unknown; volume?: unknown } | null
    if (typeof saved?.enabled === 'boolean') soundSettings.enabled = saved.enabled
    // Someone who has asked their system for less motion has not asked for surprise noises either:
    // sound starts off for them until they turn it on themselves.
    else if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) soundSettings.enabled = false
    if (typeof saved?.volume === 'number') soundSettings.volume = Math.max(0, Math.min(saved.volume, 1))
  } catch {
    // Storage can be blocked (private windows, strict settings). The defaults still work.
  }
  // In a scope of its own, so it keeps saving after whichever component happened to trigger this is gone.
  effectScope(true).run(() =>
    watch(
      () => ({ ...soundSettings }),
      (value) => {
        try {
          window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
        } catch {
          // Not being able to save the choice is not worth interrupting anyone for.
        }
      },
    ),
  )
}

let context: AudioContext | undefined

/**
 * Writes one sound into an audio graph, starting at `when`. Exported so a sound can be rendered
 * somewhere other than the speakers (an OfflineAudioContext, for tests or for exporting a file).
 */
export function scheduleSound(audio: BaseAudioContext, output: AudioNode, name: SoundName, when = audio.currentTime) {
  for (const tone of SOUNDS[name] as Tone[]) {
    const start = when + (tone.at ?? 0)
    const end = start + tone.duration
    const oscillator = audio.createOscillator()
    const envelope = audio.createGain()

    oscillator.type = tone.type ?? 'sine'
    oscillator.frequency.setValueAtTime(tone.freq, start)
    if (tone.to) oscillator.frequency.exponentialRampToValueAtTime(tone.to, end)

    // A very fast fade in and a smooth fade out. Starting or stopping a tone abruptly makes a harsh click.
    envelope.gain.setValueAtTime(0.0001, start)
    envelope.gain.exponentialRampToValueAtTime(tone.gain ?? 0.4, start + 0.004)
    envelope.gain.exponentialRampToValueAtTime(0.0001, end)

    oscillator.connect(envelope).connect(output)
    oscillator.start(start)
    oscillator.stop(end + 0.01)
  }
}

/** Plays a sound now, unless sound is switched off. Safe to call anywhere: it does nothing on a server. */
export function playSound(name: SoundName = 'click') {
  prepare()
  if (!soundSettings.enabled || soundSettings.volume <= 0 || typeof window === 'undefined') return
  const AudioContextClass = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
  if (!AudioContextClass || !(name in SOUNDS)) return

  try {
    // Created on first use, which is always inside a click or key press: browsers refuse to start audio before one.
    context ??= new AudioContextClass()
    if (context.state === 'suspended') void context.resume()
    const volume = context.createGain()
    volume.gain.value = soundSettings.volume
    volume.connect(context.destination)
    scheduleSound(context, volume, name)
  } catch {
    // A sound that cannot play is never worth breaking the click it decorates.
  }
}

/** For use inside components: `const { play, settings } = useSound()`. */
export function useSound() {
  prepare()
  return { play: playSound, settings: soundSettings }
}