@diegesis/audio
v0.2.2
Published
Howler-based audio engine for Diegesis: channels with mute/solo, fades, random groups, spatial sound and timelines.
Maintainers
Readme
@diegesis/audio
Howler-based audio engine for Diegesis: channels with mute/solo, fades, random groups, spatial sound and timelines.
@diegesis/audio wraps Howler in a structured engine:
- Channels (
music,ambient,effects,voiceby default) with volume, mute, and solo, mixed through aChannelMixer. - Transport queue —
play/pause/stopcalls are deduplicated per sound and flushed on the next tick, so rapid toggles can't corrupt state. - Random groups — pick a random member with optional no-repeat.
- Timelines — multi-track cue sheets with per-cue fades, offsets, looping, pause/resume, and crossfades between timelines.
- Spatial audio — Web Audio positional sound via
setPosition,setSpatialAttrs, andsetListener. - Events and hooks — everything flows through an
@diegesis/eventsbus (namespaceaudio) with Valibot-validated payloads;syncWaterfallhooks let plugins intercept playback and volume changes. - UUID v7 ids — sound, group, cue, and timeline ids generated by
newId(). - Testable — inject a fake
clockto make timers deterministic.
Installation
npm install @diegesis/audiohowler and @diegesis/events are regular dependencies (installed automatically).
Quick start
import { AudioEngine } from '@diegesis/audio';
const engine = new AudioEngine();
// Call after a user gesture to resume the suspended AudioContext:
document.addEventListener('click', () => engine.unlock(), { once: true });
const rain = engine.register({
src: ['ambience/rain.ogg', 'ambience/rain.mp3'],
loop: true,
channel: 'ambient',
volume: 0.6,
});
const thunder = engine.register({ src: 'sfx/thunder.mp3', channel: 'effects' });
const clang = engine.register({ src: 'sfx/clang.mp3', channel: 'effects' });
engine.play(rain);
// Random one-shots without repeating the last pick:
const hits = engine.defineGroup({ members: [thunder, clang], noRepeat: true });
engine.playGroup(hits);
// A looping ambience timeline with a thunder cue at 8s:
engine.startTimeline([
{ cues: [{ soundId: rain, startMs: 0 }] },
{ cues: [{ soundId: thunder, startMs: 8000, fadeInMs: 300 }] },
], { loop: true, fadeInMs: 1000 });
engine.bus.on('volume:changed', ({ target, id, value }) => console.log(target, id, value));
engine.setChannelVolume('ambient', 0.4);
engine.setMasterMuted(false);API
AudioEngine
interface AudioEngineOptions {
channels?: readonly string[]; // default DEFAULT_CHANNELS
clock?: Clock; // default defaultClock
bus?: AudioBus; // default: new bus from audioContract
}
class AudioEngine {
readonly bus: AudioBus;
readonly mixer: ChannelMixer;
readonly groups: GroupRegistry;
readonly clock: Clock;
constructor(options?: AudioEngineOptions);
}Registration
register(input: SoundDefInput): string; // returns the sound id (UUID v7 if omitted); throws on duplicate ids
unregister(soundId: string): boolean; // stops instances, unloads the Howl
has(soundId: string): boolean;
list(): string[];
isPlaying(soundId: string): boolean;
getDurationMs(soundId: string): number; // 0 for unknown sounds
soundLoops(soundId: string): boolean;interface SoundDef { // SoundDefInput — validated by SoundDefSchema
id?: string; // must be a UUID if provided
src: string | string[]; // Howler source(s), for format fallbacks
volume?: number; // 0..1, default 1
loop?: boolean; // default false
channel?: string; // default 'ambient'; created on demand
html5?: boolean; // default false (Web Audio)
preload?: boolean; // default true
sprite?: Sprite; // Howler sprite map: name -> [offsetMs, durationMs, loop?]
pannerAttr?: SpatialAttrs; // spatial defaults for every instance
}Transport
play(soundId), pause(soundId), stop(soundId) enqueue commands
(deduplicated per sound, last intent wins) and process them on the next clock
tick. Call flush() to process immediately.
playOnce(soundId, options?): number | null; // immediate instance; returns the Howl instance id
// PlayOnceOptions: { fadeInMs?, fadeOutMs?, durationMs?, startOffsetMs?, origin? }
playScheduled(soundId, options?): ScheduledHandle; // PlayScheduledOptions adds delayMs; handle.cancel() aborts
fade(soundId, from: number, to: number, durationMs: number): void;
stopAll(): void;
pauseAll(): void;
resumeAll(): void;
pauseInstances(origin: string): void; // by origin tag ('user', 'timeline', 'timeline:<id>:<trackId>', ...)
resumeInstances(origin: string): void;
stopInstances(origin: string): void;
unlock(): void; // resumes the suspended Web Audio context
destroy(): void;Volume, mute, channels
setVolume(soundId: string, volume: number): void;
setMuted(soundId: string, muted: boolean): void;
setChannel(soundId: string, channel: string): void;
setChannelVolume(id: string, volume: number): void;
setChannelMuted(id: string, muted: boolean): void;
setChannelSolo(id: string, solo: boolean): void;
setMasterVolume(volume: number): void;
setMasterMuted(muted: boolean): void;All volume setters route through the beforeVolume hook before applying.
Spatial audio
Active only when Howler is using Web Audio; no-ops on the HTML5 fallback.
setPosition(soundId: string, x: number, y: number, z?: number): void;
setSpatialAttrs(soundId: string, attrs: SpatialAttrsInput): void;
setListener(x: number, y: number, z?: number, forward?: Vec3, up?: Vec3): void;
// Vec3 = readonly [number, number, number]; defaults: forward [0,0,-1], up [0,1,0]SpatialAttrs (validated by SpatialAttrsSchema): panningModel
('equalpower' | 'HRTF', default 'equalpower'), distanceModel
('linear' | 'inverse' | 'exponential', default 'inverse'), refDistance,
maxDistance, rolloffFactor, coneInnerAngle, coneOuterAngle,
coneOuterGain.
Random groups
engine.defineGroup(input: SoundGroupInput): string; // { id?, members: string[], noRepeat?: true }
engine.playGroup(groupId: string): string | null; // picks a member and playOnce()s itOr use the registry directly: GroupRegistry#define/get/list/remove/addMembers/pick/clear.
With noRepeat (default true) and more than one member, the last pick is excluded.
Timelines
startTimeline(tracks: TimelineTrackInput[], options?: { loop?: boolean; fadeInMs?: number }): TimelineHandle;
crossfadeToTimeline(tracks: TimelineTrackInput[], options?: { durationMs?: number; loop?: boolean; fadeInMs?: number }): TimelineHandle;
stopTimeline(): void;
pauseTimeline(): void;
resumeTimeline(): void;
get activeTimeline(): TimelineHandle | null;
interface TimelineHandle {
readonly id: string; // UUID v7
readonly loop: boolean;
readonly paused: boolean;
stop(): void;
pause(): void;
resume(): void;
}Tracks and cues (validated by TimelineTrackSchema / TimelineCueSchema):
interface TimelineTrack { id?: string; cues: TimelineCue[]; loop?: boolean; }
interface TimelineCue {
id?: string;
soundId: string;
startMs: number; // position on the timeline
durationMs?: number; // omit to play until the sound/track ends
fadeInMs?: number; // defaults to the timeline's fadeInMs
fadeOutMs?: number; // default 0
startOffsetMs?: number; // seek into the sound
}Starting a timeline replaces the current one. crossfadeToTimeline fades the
old timeline's instances out over durationMs (default 2000) while the new one
fades in. pause() stops cue timers and instances; resume() reschedules the
remaining cues with correct offsets (looping sounds wrap). TimelineRunner is
exported for advanced use.
ChannelMixer and DEFAULT_CHANNELS
const DEFAULT_CHANNELS = ['music', 'ambient', 'effects', 'voice'] as const;
class ChannelMixer {
constructor(channelIds?: readonly string[]);
list(): string[];
get(id: string): ChannelState | undefined; // { volume, muted, solo }
ensure(id: string): ChannelState;
setChannelVolume(id: string, volume: number): void;
setChannelMuted(id: string, muted: boolean): void;
setChannelSolo(id: string, solo: boolean): void;
get master(): ChannelState;
setMasterVolume(volume: number): void;
setMasterMuted(muted: boolean): void;
anySolo(): boolean;
channelGain(id: string): number; // 0 when muted, or when another channel is soloed
masterGain(): number;
effective(id: string): number; // channelGain * masterGain
onChange(listener: () => void): () => void;
}Events and hooks
audioContract (also exported as audioEvents / audioHooks) is an
@diegesis/events contract, namespace audio, validated in 'throw' mode.
Types: AudioBus, AudioContract, AudioEventMap, AudioHookMap.
Events: sound:played { soundId, channel }, sound:paused { soundId },
sound:stopped { soundId }, fade:ended { soundId, from, to },
volume:changed { target: 'master' | 'channel' | 'sound', id?, value },
mute:changed { target, id?, muted }, timeline:started { loop },
timeline:ended { loop }.
Hooks (syncWaterfall — tap to transform the value before it applies):
| Hook | Context | Use |
|------|---------|-----|
| beforePlay | { soundId, volume, channel } | Rewrite volume/channel per play (ducking, overrides). |
| beforeVolume | { target, id?, value } | Clamp or veto volume changes globally. |
engine.bus.tap('beforePlay', 'ducking', (ctx) =>
ctx.channel === 'effects' ? { ...ctx, volume: ctx.volume * 0.5 } : ctx
);Schemas
All exported as Valibot schemas with matching input/output types:
SoundDefSchema (SoundDef, SoundDefInput), SoundGroupSchema
(SoundGroup, SoundGroupInput), TimelineTrackSchema / TimelineCueSchema
(TimelineTrack(Input), TimelineCue(Input)), SpatialAttrsSchema
(SpatialAttrs(Input)), SpriteSchema (Sprite), UnitIntervalSchema.
Related packages
- @diegesis/events — event/hook bus powering the audio contract
- @diegesis/dice — uses this engine for dice collision sounds
- @diegesis/assets — preload audio files before registering them
Part of diegesis-sdk. MIT.
