soundhub
v6.2.2
Published
One audio hub for your app: play, group, fade, sprite and position sounds through a single typed event bus, and stream long files. Web Audio API, 21 KB gzipped, zero dependencies.
Maintainers
Readme
soundhub.js
One hub for all the audio in your app. Load sounds once, address them by id, and listen to a single typed event bus instead of wiring callbacks per sound.
Built directly on the Web Audio API. 21 KB gzipped, zero dependencies, written in TypeScript and usable from plain JavaScript.
npm install soundhubQuick start
import { SoundHub, SoundEventsEnum } from 'soundhub';
const hub = new SoundHub({ masterLimiter: true });
await hub.loadSounds([
{ id: 'music', url: '/audio/theme.mp3' },
{ id: 'laser', url: '/audio/laser.wav' },
]);
hub.play('music', { loop: true, volume: 0.6, fadeInDuration: 2 });
// Overlapping one-shots: each call gets its own instance.
hub.play('laser', { overlap: true });
// One bus, one place to react.
hub.addEventListener(SoundEventsEnum.PROGRESS, (event) => {
progressBar.value = event.state!.progress;
});What soundhub is built for
You address every sound by id and one hub holds the graph behind it, so the state of your audio sits in one place instead of spread over your components.
- One typed event bus with 38 event types. A filter per listener narrows it down to a single sound, one overlapping instance, or every instance that matches a naming pattern.
- The progress event carries the whole state.
event.stateis the sameSoundStateInfoobject thatgetSoundState()returns: progress, currentTime, duration, playbackRate, volume, pan, spatial position and the playback state. A seek bar reads it straight off the event instead of polling for it. - Long files stay in the graph.
loadStreamplays an hour of audio without decoding it first, and it still runs through the panner and the gain nodes, so an ambience track gets the same effects and the same 3D position as a sample of half a second. - Ceilings instead of your own bookkeeping.
createSoundGroupwithmaxInstancescaps a group, andmaxInstancesPerSounddoes the same for every sound in the hub without needing a group for it. Hit the cap and the oldest instance stops. A master limiter is one config flag, gapless looping is one play option, and the lock screen controls are one call tosetMediaSession.
hub.addEventListener(SoundEventsEnum.PROGRESS, (event) => {
const { progress, currentTime, duration } = event.state!;
seekBar.value = progress;
timeLabel.textContent = `${format(currentTime)} / ${format(duration)}`;
}, { soundId: 'music' });| | soundhub | Howler.js |
| --- | --- | --- |
| Events | one typed bus, 38 types, filter per listener | callbacks per Howl |
| Progress | the full state on the event, plus getSoundState | poll seek() yourself |
| Long files | loadStream, still in the Web Audio graph | html5: true, outside it |
| Spatial on long files | yes | no, HTML5 mode skips the panner |
| Groups | createSoundGroup with maxInstances | your own bookkeeping |
| Limiter | masterLimiter: true | build it yourself on Howler.ctx |
soundhub is for apps that run a lot of audio at once. A game, a player, anything where the music and the one-shots have to stay under control from one place.
Playing a sound many times at once
By default a sound has one voice. Play it again while it is still running and it starts over from the top, which is what you want for music and for a voice-over.
For footsteps, lasers, coins and UI clicks you want the opposite. Set overlap
and every call gets its own instance, so the sounds stack instead of cutting each
other off:
hub.play('laser'); // restarts, one laser at a time
hub.play('laser', { overlap: true }); // stacks, ten lasers if you click ten timesEach instance gets its own id, laser:1, laser:2 and so on. play() returns
the instance, so you can address a single one:
const shot = hub.play('laser', { overlap: true });
hub.setSoundVolume(shot!.id, 0.4);
hub.stop(shot!.id);Instances clean themselves up when they end. To put a ceiling on how many can run at the same time, play them into a group:
hub.createSoundGroup('lasers', { maxInstances: 8 });
hub.play('laser', { overlap: true, groupId: 'lasers' });The ninth laser stops the oldest one instead of piling up. Without a group,
new SoundHub({ maxInstancesPerSound: 8 }) does the same for every sound at
once, which is a cheap guard against a stuck key.
On the event bus, the filter tells the two apart. { soundId: 'laser' } matches
that one id, { originalId: 'laser' } matches every instance of it:
hub.addEventListener(SoundEventsEnum.ENDED, (event) => {
console.log('finished:', event.soundId); // laser:3
}, { originalId: 'laser' });You can switch the default over for the whole hub with new SoundHub({ overlap:
true }). It stays off unless you ask for it, because overlapping playback changes
what stop(id) and pause(id) reach: those act on the original id, and the
running instances have their own.
overlapused to be calledcreateNewInstance. The old name still works and is removed in v7. See migrating.
Sprites
One file, many sounds. Load the sprite sheet, name the ranges in seconds, then play them by name:
await hub.loadSound('ui', '/audio/ui-sprites.mp3');
hub.setSoundSprite('ui', {
click: [0, 0.2], // [start, end] in seconds
hover: [0.5, 0.7],
error: [1, 1.8],
});
hub.playSprite('ui', 'click');
hub.playSprite('ui', 'error', { volume: 0.8 });setSoundSprite cuts each range into its own buffer once, so playing a sprite is
as cheap as playing any other sound and it starts exactly on the sample you asked
for. The two go together well: hub.playSprite('ui', 'click', { overlap: true })
lets a fast typist trigger the same click twenty times without it stuttering.
Sprites need the samples in memory, so they work on sounds loaded with
loadSound and not on streams.
Loading
Give a sound a list of urls and the browser picks the one it can play. The check runs before anything is fetched, so the files it cannot use are never requested:
await hub.loadSound('theme', [
'/audio/theme.opus', // Chrome, Firefox, Edge
'/audio/theme.m4a', // Safari
]);
SoundHub.canPlay('opus'); // false on older Safari
SoundHub.getSupportedFormats(); // ['mp3', 'wav', 'm4a', ...]A url without a known extension, a signed CDN link for example, is used as is. soundhub would rather try and fail than refuse to load anything.
Sounds you do not need at startup can be written down and fetched later:
hub.registerSounds([
{ id: 'boss-music', url: ['/audio/boss.opus', '/audio/boss.mp3'] },
{ id: 'victory', url: '/audio/victory.mp3' },
]);
hub.getLoadState('boss-music'); // 'unloaded'
await hub.loadSound('boss-music'); // no url needed, it is on file
hub.getLoadState('boss-music'); // 'loading', then 'loaded' or 'error'loading events fire on the same bus, so a spinner is four lines. For audio
behind a token, fetchHeaders goes on every request:
const hub = new SoundHub({
fetchHeaders: { Authorization: `Bearer ${token}` },
});A closer look
"What soundhub is built for" is the short version. This is the same ground with the code on it, plus the parts that did not fit there.
Filters and unsubscribing. A listener can be narrowed to one sound, one
overlapping instance, or every instance matching a pattern, and
addEventListener hands back the function that removes it again:
const off = hub.addEventListener(SoundEventsEnum.PROGRESS, (event) => {
bar.value = event.state!.progress; // no id check needed
}, { soundId: 'music' });
hub.once(SoundEventsEnum.ENDED, playNextTrack, { soundId: 'music' });
off(); // addEventListener hands back its own unsubscribeWhat a group carries. A group has its own play options, so
play(id, { groupId }) inherits looping, volume and the rest from the group
instead of repeating them per call. The master limiter is off by default:
turning it on is a deliberate change to how your project sounds.
A listener you can move. setSpatialPosition moves a sound around the ear,
which is what a map or a menu needs. A first-person camera works the other way
round: the sounds stay where they are and you move. setListenerPosition and
setListenerOrientation do that, and setSpatialOrientation points a sound in
a direction, which is what makes the cone settings on the panner mean something.
hub.setListenerPosition(player.x, 0, player.z);
hub.setListenerOrientation(camera.x, 0, camera.z);
hub.setSpatialOrientation('television', 0, 0, -1); // facing into the roomSleeping on battery. A running audio context keeps the audio hardware awake
even when nothing plays. With autoSuspend: true the context goes to sleep after
thirty seconds of silence and the next play() wakes it. Off by default, because
waking up costs a few milliseconds and a game that fires sounds constantly is
better off awake.
An escape hatch. getMasterInput() and getMasterOutput() let you route your
own oscillators through the master chain, or hang an AnalyserNode off the output
for a visualiser. The library never gets in your way.
Also included: seamless looping, fades per sound and globally, playback rate, stereo panning, 3D spatial positioning with HRTF, cross-origin loading with retries, and mobile handling (auto-unlock, auto-mute when the tab hides, auto-resume on focus).
Why a stream is a different thing. Short sounds are decoded into memory,
which is what makes precise scheduling, sprites and instance stacking possible.
An hour-long podcast loaded that way would cost hundreds of megabytes and a long
wait before the first sound. So loadStream takes the other route: the browser
fetches as it plays.
await hub.loadStream('episode-42', '/audio/episode-42.mp3');
hub.play('episode-42');
hub.setPlaybackRate('episode-42', 1.5); // podcast listeners want this
hub.seek('episode-42', 1800); // jump half an hour inPlayback, seeking, volume, fades, mute, panning, playback rate, looping, state and
progress events behave the same as for a buffered sound, on the same event bus.
What a stream cannot do is anything that needs random access to samples: sprites
and overlap are unavailable, and looping is handled by the browser, so no
loop_completed event fires. getStreamElement(id) hands you the media element
for the rest, such as buffered ranges for a loading bar.
The lock screen works. setMediaSession puts a title, artist and artwork on
the operating system's media controls and wires up the hardware keys for play,
pause, skip back fifteen, skip forward thirty, and the scrubber:
hub.setMediaSession('episode-42', {
title: 'Episode 42: naming things',
artist: 'The Podcast',
artwork: [{ src: '/cover-512.png', sizes: '512x512', type: 'image/png' }],
onNextTrack: () => playEpisode(43),
});soundhub keeps the playback state and the scrubber position in step as the
sound plays; clearMediaSession() takes it off again.
Examples
The examples/ folder holds a single page that exercises the whole public API:
sprites, overlapping instances, deferred loading, progress and seeking, groups,
fades, panning, spatial audio with a listener you can move, and a live view of
the event bus.
npm install
npm run devEvery sound in examples/sounds/ is synthesised by
scripts/generate-example-sounds.py. Nothing there is sampled or downloaded, so
the example audio carries the same MIT licence as the rest of the project.
Tests
npm test # once
npm run test:watch # while you work
npm run test:coverageVitest on jsdom, with a Web Audio mock in tests/support. The mock is a plain
stand-in: nodes remember what they are connected to, audio params remember their
value, the clock only moves when a test moves it, and a buffer source refuses a
second start() the way the real one does. That is enough to run the library
itself rather than a rehearsal of it, so the tests cover loading, playback,
overlap, sprites, groups, fades, panning, spatial audio, the listener, streams,
the media session and the event bus.
API
Full reference: soundhub-docs.chriscreativecode.com
The shape of it:
| Area | Methods |
| --- | --- |
| Loading | loadSound loadSounds registerSound registerSounds loadStream updateSoundUrl unloadSound removeSound isSoundLoaded getLoadState getSoundUrls canPlay getSupportedFormats |
| Playback | play playSprite pause resume stop seek stopAllSounds pauseAllSounds resumeAllSounds |
| Volume & mute | setSoundVolume setGlobalVolume mute unmute toggleGlobalMute fadeIn fadeOut fadeGlobalIn fadeGlobalOut |
| State | getSoundState isPlaying isPaused getProgress getDuration startProgressTracking |
| Groups | createSoundGroup addToSoundGroup removeFromSoundGroup getGroup removeSoundGroup |
| Sprites | setSoundSprite getSpriteConfig removeSpriteConfig |
| Panning | setPan setGlobalPan resetPan isStereoPanActive |
| Spatial | setSpatialPosition setSpatialOrientation setMasterSpatialPosition setMasterSpatialOrientation updatePannerConfigById removeSpatialEffect |
| Listener | setListenerPosition setListenerOrientation getListenerPosition getListenerOrientation resetListener |
| Streaming | loadStream isStream getStreamElement |
| Graph | getContext getMasterInput getMasterOutput setMasterLimiter getMasterLimiterNode suspendContext resumeContext |
| Events | addEventListener once removeEventListener dispatchEvent hasEventListener |
| Media Session | setMediaSession clearMediaSession |
Browser support
Every current browser: Chrome, Edge, Firefox and Safari, desktop and mobile.
Migrating
From createNewInstance to overlap
createNewInstance is now called overlap. It does the same thing, the default
is still off, and the old name keeps working until v7. Both the play options and
the hub config accept either, and overlap wins if you pass both.
-hub.play('laser', { createNewInstance: true });
+hub.play('laser', { overlap: true });Nothing breaks if you change nothing. Your editor will mark the old name as deprecated, which is the reminder.
From sound-manager-ts
soundhub is the continuation of sound-manager-ts. The API is unchanged; the
package and the main class were renamed.
-import { SoundManager } from 'sound-manager-ts';
-const manager = new SoundManager();
+import { SoundHub } from 'soundhub';
+const hub = new SoundHub();SoundManager and SoundManagerConfig are still exported as deprecated aliases,
so existing code compiles unchanged. They will be removed in v7.
Contributing
Issues and pull requests are welcome. If you hit an edge case, a reproduction in the examples page is the fastest way to get it fixed.
Licence
MIT © Chris Schardijn
