Skip to main content

Svelte

soundhub has no Svelte-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 function built on runes.

A complete Svelte 5 app with a music player and overlapping effects is in the repository at examples/svelte. 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/lib/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;
}

A sound as reactive state​

Runes work in .svelte.ts files too, so the state of a sound can live in a plain function. addEventListener takes a filter, so it listens to one sound only, and it returns a function that removes the listener. The $effect returns those, and Svelte runs them when the component is destroyed.

src/lib/sound/state.svelte.ts
import { SoundEventsEnum, type SoundStateInfo } from 'soundhub';
import { getHub } from './hub';

const STATE_EVENTS = [
SoundEventsEnum.STARTED,
SoundEventsEnum.PAUSED,
SoundEventsEnum.RESUMED,
SoundEventsEnum.STOPPED,
SoundEventsEnum.ENDED,
SoundEventsEnum.SEEKED,
SoundEventsEnum.PROGRESS,
];

export function soundState(id: string): { readonly current: SoundStateInfo | undefined } {
let current = $state<SoundStateInfo>();

$effect(() => {
const hub = getHub();
const update = () => (current = hub.getSoundState(id));
update();
const removers = STATE_EVENTS.map((type) => hub.addEventListener(type, update, { soundId: id }));
return () => removers.forEach((remove) => remove());
});

return {
get current() {
return current;
},
};
}

Call soundState while a component initialises, in its <script> block. The state is the same object getSoundState returns: the playback state, currentTime, duration, progress, volume and pan.

A player component​

src/lib/MusicPlayer.svelte
<script lang="ts">
import { SoundState } from 'soundhub';
import { getHub } from './sound/hub';
import { soundState } from './sound/state.svelte';

const music = soundState('music');
const playing = $derived(music.current?.state === SoundState.Playing);

function toggle() {
const hub = getHub();
const state = music.current?.state;
if (state === SoundState.Playing) hub.pause('music');
else if (state === 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>

<button onclick={toggle}>{playing ? 'Pause' : 'Play'}</button>
<input
type="range"
min="0"
step="0.1"
max={music.current?.duration || 1}
value={music.current?.currentTime ?? 0}
oninput={seek}
/>

Effects need no state at all. Call the hub from the handler:

<button onclick={() => 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.

SvelteKit​

The code above runs in SvelteKit as it is. $effect and onMount never run on the server, and getHub() creates the hub on first use, so importing hub.ts during server rendering does nothing.

Put the files in src/lib/ and import them with $lib/. Load the sounds in onMount of the page or layout that needs them:

src/routes/+page.svelte
<script lang="ts">
import { onMount } from 'svelte';
import MusicPlayer from '$lib/MusicPlayer.svelte';
import { loadSounds } from '$lib/sound/hub';

let loaded = $state(false);
onMount(() => {
loadSounds().then(() => (loaded = true));
});
</script>

{#if loaded}
<MusicPlayer />
{/if}