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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add soundpnpm dlx ermineui add soundyarn dlx ermineui add soundbunx ermineui add soundThat 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
| File | Where it goes |
|---|---|
SoundToggle.vue | src/components/ui/sound/SoundToggle.vue |
directive.ts | src/components/ui/sound/directive.ts |
index.ts | src/components/ui/sound/index.ts |
sound.ts | src/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 }
}