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