Skip to main content
utils-sound wraps Howler.js to provide a structured audio system with three playback modes: looping music, looping sound effects, and one-shot sound effects. Volume levels are automatically synced to the global stateSound from state-shared.

createSound()

Factory function typed by your game’s sound name union. Create one instance per game.
import { createSound } from 'utils-sound';

type SoundName = 'bgMusic' | 'spinReel' | 'winCoin' | 'buttonClick';

const sound = createSound<SoundName>();

Return value

{
  load:          (loadedAudio: LoadedAudio<TSoundName>) => { destroy: () => void };
  stop:          (stopOptions: StopOptions<TSoundName>) => void;
  fade:          (fadeOptions: FadeOptions<TSoundName>) => Promise<void>;
  rate:          (rateOptions: RateOptions<TSoundName>) => void;
  volumeEffect:  () => void;  // register $effect to sync volume from stateSound
  enableEffect:  () => void;  // register $effect to mute/unmute on tab visibility
  get players(): { music: Player; loop: Player; once: Player };
}

load()

Must be called once after assets have loaded. Receives a LoadedAudio object from stateApp.loadedAssets (populated by pixi-svelte).
const { destroy } = sound.load(stateApp.loadedAssets['sounds']);
Internally creates a single Howl instance and three Player instances:
PlayerLoopBehaviour
players.musicYesOnly one music track plays at a time. Starting a new track pauses the current one.
players.loopYesStarts a looping sound effect; does nothing if already playing.
players.onceNoPlays a one-shot sound; auto-removes from map on end. Supports forcePlay to restart.

Player methods

Each player in sound.players exposes:
{
  play:  (options: PlayOptions<TSoundName> & { forcePlay?: boolean }) => void;
  stop:  (options: StopOptions<TSoundName>) => void;
  fade:  (options: FadeOptions<TSoundName>) => Promise<void>;
  rate:  (options: RateOptions<TSoundName>) => void;
  volume:(volume: number) => void;
  howl:  Howl;
  debug: () => void;
}

Playback examples

// Play background music (resumes if paused, no-op if already playing)
sound.players.music.play({ name: 'bgMusic' });

// Play a looping sound effect
sound.players.loop.play({ name: 'reelSpin' });

// Stop the looping sound effect
sound.stop({ name: 'reelSpin' });

// Play a one-shot sound
sound.players.once.play({ name: 'winCoin' });

// Force restart a one-shot (even if currently playing)
sound.players.once.play({ name: 'buttonClick', forcePlay: true });

// Fade out music over 1 second
await sound.fade({ name: 'bgMusic', from: 1, to: 0, duration: 1000 });

// Change playback rate (e.g. turbo mode)
sound.rate({ name: 'reelSpin', rate: 1.5 });

Type reference

export type PlayOptions<TSoundName>  = { name: TSoundName };
export type StopOptions<TSoundName>  = { name: TSoundName };
export type FadeOptions<TSoundName>  = { name: TSoundName; from: number; to: number; duration: number };
export type RateOptions<TSoundName>  = { name: TSoundName; rate: number };

export type SoundState   = 'new' | 'playing' | 'paused';
export type SoundConfig  = { volume: number };

export type GetSound<TSoundName> = {
  soundId:     number;
  soundName:   TSoundName;
  soundState:  SoundState;
  soundConfig: SoundConfig; // per-sound volume multiplier from the asset config
  soundVolume: number;      // current fade volume (0–1)
};

Volume system

Volume is controlled globally via stateSound from state-shared. All three players observe this state via Svelte $effect.
// state-shared/src/stateSound.svelte.ts
const DEFAULT_VOLUME_VALUE = 75; // 0–100 scale

export const stateSound = $state({
  volumeValueMaster:      DEFAULT_VOLUME_VALUE,
  volumeValueMusic:       DEFAULT_VOLUME_VALUE,
  volumeValueSoundEffect: DEFAULT_VOLUME_VALUE,
});

export const stateSoundDerived = {
  volumeMaster:      () => stateSound.volumeValueMaster / 100,
  volumeMusic:       () => (stateSound.volumeValueMusic / 100)       * stateSoundDerived.volumeMaster(),
  volumeSoundEffect: () => (stateSound.volumeValueSoundEffect / 100) * stateSoundDerived.volumeMaster(),
};
Call sound.volumeEffect() inside a Svelte component (e.g. the root Game.svelte) to register the reactive effects that keep player volume in sync:
<script lang="ts">
  import { onMount } from 'svelte';
  import { sound } from '../game/sound';

  // Register volume and enable/disable reactive effects
  sound.volumeEffect();
  sound.enableEffect();
</script>

Enable / disable

enableEffect() registers a $effect that calls Howler.mute(true) whenever the tab is hidden or the AudioContext is not running (e.g. the browser blocked autoplay), and Howler.mute(false) when both conditions clear:
const enableEffect = () => {
  $effect(() => {
    if (audioContextState === 'running' && visibilityState === 'visible') {
      Howler.volume(1);
      Howler.mute(false);
    } else {
      Howler.volume(0);
      Howler.mute(true);
    }
  });
};
The UI components in components-ui-pixi expose a ButtonSoundSwitch component that lets the player toggle sound on/off by writing to stateSound.volumeValueMaster.

Full setup example

// apps/lines/src/game/sound.ts
import { createSound } from 'utils-sound';

export type SoundName =
  | 'bgMusic'
  | 'bonusMusic'
  | 'spinReel'
  | 'stopReel'
  | 'winSmall'
  | 'winBig';

export const sound = createSound<SoundName>();
<!-- apps/lines/src/components/Game.svelte -->
<script lang="ts">
  import { sound } from '../game/sound';
  import { getContextApp } from 'pixi-svelte';

  const { stateApp } = getContextApp();

  // Register effects inside component lifecycle
  sound.volumeEffect();
  sound.enableEffect();

  $effect(() => {
    if (stateApp.loaded) {
      const { destroy } = sound.load(stateApp.loadedAssets['sounds']);
      return destroy; // cleanup on component destroy
    }
  });
</script>

Build docs developers (and LLMs) love