Skip to main content

Angular

soundhub has no Angular-specific package and does not need one. The pattern is small: a service provided in root holds the hub, and a function turns the state of one sound into a signal.

A complete app with a music player and overlapping effects is in the repository at examples/angular. It uses standalone components, signals and no zone.js. Open it in StackBlitz to try it without installing anything.

npm install soundhub

A service for the hub​

A service provided in root gives every component the same hub. The service only creates the hub in the browser: with server-side rendering it also runs on the server, where there is no AudioContext.

src/app/sound/sound.service.ts
import { DestroyRef, Injectable, PLATFORM_ID, inject, signal } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { SoundHub, type PlayOptions } from 'soundhub';

const SOUNDS = [
{ id: 'music', url: '/audio/music.mp3' },
{ id: 'laser', url: '/audio/laser.wav' },
];

@Injectable({ providedIn: 'root' })
export class SoundService {
readonly hub = isPlatformBrowser(inject(PLATFORM_ID)) ? new SoundHub({ masterLimiter: true }) : undefined;

private readonly loadedSignal = signal(false);
readonly loaded = this.loadedSignal.asReadonly();

constructor() {
this.hub?.loadSounds(SOUNDS).then(() => this.loadedSignal.set(true));
inject(DestroyRef).onDestroy(() => this.hub?.destroy());
}

play(id: string, options?: PlayOptions): void {
this.hub?.play(id, options);
}

pause(id: string): void {
this.hub?.pause(id);
}

resume(id: string): void {
this.hub?.resume(id);
}

stop(id: string): void {
this.hub?.stop(id);
}

seek(id: string, seconds: number): void {
this.hub?.seek(id, seconds);
}
}

A sound as a signal​

addEventListener takes a filter, so you can listen to one sound only. It returns a function that removes the listener, and DestroyRef calls those when the component goes away.

src/app/sound/sound.service.ts
import { DestroyRef, assertInInjectionContext, inject, signal, type Signal } from '@angular/core';
import { SoundEventsEnum, type SoundStateInfo } from 'soundhub';

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

export function injectSoundState(id: string): Signal<SoundStateInfo | undefined> {
assertInInjectionContext(injectSoundState);
const hub = inject(SoundService).hub;
const state = signal<SoundStateInfo | undefined>(hub?.getSoundState(id));
if (!hub) return state.asReadonly();

const update = () => state.set(hub.getSoundState(id));
const removers = STATE_EVENTS.map((type) => hub.addEventListener(type, update, { soundId: id }));
inject(DestroyRef).onDestroy(() => removers.forEach((remove) => remove()));

return state.asReadonly();
}

The state is the same object getSoundState returns: the playback state, currentTime, duration, progress, volume and pan. Setting a signal schedules change detection, so this works without zone.js.

A player component​

Call injectSoundState in a field initializer, where Angular's injection context is available.

src/app/music-player.ts
import { Component, computed, inject } from '@angular/core';
import { SoundState } from 'soundhub';
import { SoundService, injectSoundState } from './sound/sound.service';

@Component({
selector: 'app-music-player',
template: `
<button (click)="toggle()">{{ playing() ? 'Pause' : 'Play' }}</button>
<input
type="range"
min="0"
step="0.1"
[max]="state()?.duration || 1"
[value]="state()?.currentTime ?? 0"
(input)="seek($event)"
/>
`,
})
export class MusicPlayer {
private readonly sound = inject(SoundService);
protected readonly state = injectSoundState('music');
protected readonly playing = computed(() => this.state()?.state === SoundState.Playing);

protected toggle(): void {
const state = this.state()?.state;
if (state === SoundState.Playing) this.sound.pause('music');
else if (state === SoundState.Paused) this.sound.resume('music');
else this.sound.play('music', { loop: true, fadeInDuration: 1 });
}

protected seek(event: Event): void {
this.sound.seek('music', Number((event.target as HTMLInputElement).value));
}
}

Effects need no state at all. Call the service from the template:

<button (click)="sound.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.