Vue
soundhub has no Vue-specific package and does not need one. The pattern is small: keep one hub in a module, load the sounds once, and follow the sounds you show with a composable.
A complete app with a music player and overlapping effects is in the
repository at examples/vue.
Open it in StackBlitz
to try it without installing anything.
npm install soundhub
One hub for the app
Create the hub on first use and keep it in a module, so every component shares
it. Creating it on first use also keeps it off the server, where there is no
AudioContext.
import { SoundHub } from 'soundhub';
export const SOUNDS = [
{ id: 'music', url: '/audio/music.mp3' },
{ id: 'laser', url: '/audio/laser.wav' },
];
let hub: SoundHub | undefined;
let loading: Promise<void> | undefined;
export function getHub(): SoundHub {
hub ??= new SoundHub({ masterLimiter: true });
return hub;
}
/** Loads every sound once, however many components ask for it. */
export function loadSounds(): Promise<void> {
loading ??= getHub().loadSounds(SOUNDS);
return loading;
}
Following a sound with a composable
addEventListener takes a filter, so a composable can
listen to one sound only. It returns a function that removes the listener, and
the composable calls those when the component unmounts.
import { onBeforeUnmount, onMounted, readonly, ref, shallowRef, type Ref } from 'vue';
import { SoundEventsEnum, type SoundStateInfo } from 'soundhub';
import { getHub, loadSounds } from './hub';
export function useSoundsLoaded(): Readonly<Ref<boolean>> {
const loaded = ref(false);
onMounted(() => {
loadSounds().then(() => (loaded.value = true));
});
return readonly(loaded);
}
const STATE_EVENTS = [
SoundEventsEnum.STARTED,
SoundEventsEnum.PAUSED,
SoundEventsEnum.RESUMED,
SoundEventsEnum.STOPPED,
SoundEventsEnum.ENDED,
SoundEventsEnum.SEEKED,
SoundEventsEnum.PROGRESS,
];
export function useSoundState(id: string): Readonly<Ref<SoundStateInfo | undefined>> {
const state = shallowRef<SoundStateInfo>();
let removers: Array<() => void> = [];
onMounted(() => {
const hub = getHub();
const update = () => (state.value = hub.getSoundState(id));
update();
removers = STATE_EVENTS.map((type) => hub.addEventListener(type, update, { soundId: id }));
});
onBeforeUnmount(() => removers.forEach((remove) => remove()));
return state;
}
The state is the same object getSoundState returns:
the playback state, currentTime, duration, progress, volume and pan. Each
event hands back a new object, so a shallowRef is enough.
A player component
<script setup lang="ts">
import { computed } from 'vue';
import { SoundState } from 'soundhub';
import { getHub } from '../sound/hub';
import { useSoundState } from '../sound/composables';
const state = useSoundState('music');
const playing = computed(() => state.value?.state === SoundState.Playing);
function toggle() {
const hub = getHub();
const current = state.value?.state;
if (current === SoundState.Playing) hub.pause('music');
else if (current === SoundState.Paused) hub.resume('music');
else hub.play('music', { loop: true, fadeInDuration: 1 });
}
function seek(event: Event) {
getHub().seek('music', Number((event.target as HTMLInputElement).value));
}
</script>
<template>
<button @click="toggle">{{ playing ? 'Pause' : 'Play' }}</button>
<input
type="range"
min="0"
step="0.1"
:max="state?.duration || 1"
:value="state?.currentTime ?? 0"
@input="seek"
/>
</template>
Effects need no state at all. Call the hub from the handler:
<button @click="getHub().play('laser', { overlap: true })">Fire</button>
With overlap: true every click starts its own instance, so fast clicks do not
cut each other off.
Browsers keep audio silent until the visitor clicks, taps or presses a key. Call
the first play() from an event handler, as above, and soundhub resumes the
audio context for you.
Nuxt
The code above runs in Nuxt as it is. The composables only touch the hub in
onMounted, which never runs on the server, and getHub() creates the hub on
first use, so importing hub.ts during server rendering does nothing. You do
not need <ClientOnly> or a client plugin.
Put hub.ts and composables.ts in the composables/ folder and Nuxt imports
them for you. Any code of your own that calls getHub() belongs in
onMounted, an event handler or a .client.ts plugin.