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.
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.
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
<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.
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:
<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}