Skip to main content

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.

src/sound/hub.ts
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.

src/sound/composables.ts
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​

src/components/MusicPlayer.vue
<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.

Start audio from a click

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.