npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

Readme

soundhub.js

npm version License: MIT Minzipped size Total downloads JavaScript TypeScript

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 soundhub

Quick 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.state is the same SoundStateInfo object that getSoundState() 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. loadStream plays 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. createSoundGroup with maxInstances caps a group, and maxInstancesPerSound does 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 to setMediaSession.
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 times

Each 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.

overlap used to be called createNewInstance. 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 unsubscribe

What 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 room

Sleeping 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 in

Playback, 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 dev

Every 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:coverage

Vitest 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