createAudioEngine
projects/common/audio/engine/audio-engine.ts
Description#
Create the audio engine: composes the autoplay gate, tone generator, and
debouncer over one lazily-created shared AudioContext. Reduced-motion muting
reuses the existing injectMediaQuery('(prefers-reduced-motion: reduce)')
primitive rather than adding another matchMedia reader.
The four mute conditions are enforced centrally in play()/tone()/
sequence(): global mute, reduced-motion, and autoplay-not-armed here;
per-element [audioDisabled] in the directives.
Runs in an injection context (calls inject() for DOCUMENT, DestroyRef, the
tone-generator factory, and the reduced-motion query). The engine instance is
resolved through CNGX_AUDIO_ENGINE_FACTORY; scope it to a subtree with
provideCngxAudioAt(...) rather than calling this directly.
Type#
CngxAudioEngineFactoryDefault value#
(options) => {
const doc = inject(DOCUMENT);
const destroyRef = inject(DestroyRef);
const toneGeneratorFactory = inject(CNGX_AUDIO_TONE_GENERATOR_FACTORY);
const config = injectAudioConfig();
const reducedMotion = injectMediaQuery('(prefers-reduced-motion: reduce)');
const muted = signal(config.muted);
const volume = signal(clampVolume(config.volume));
const status = signal('idle');
const lastPlayed = signal(null);
const gate = createAutoplayGate({ target: doc, destroyRef });
const debouncer = createDebouncer({ windowMs: config.debounceMs });
const earcons = new Map(
Object.entries({ ...CNGX_AUDIO_DEFAULT_EARCONS, ...config.earcons }),
);
// Dev-only: an unknown earcon name is a fixed authoring mistake, so warn once
// per name rather than on every (debounced) play attempt.
const warnedUnknownEarcons = new Set();
let context: BaseAudioContext | null = null;
let masterGain: GainNode | null = null;
const contextFactory =
options?.contextFactory ??
((): BaseAudioContext | null => {
const view = doc.defaultView as
| (Window & {
AudioContext?: typeof AudioContext;
webkitAudioContext?: typeof AudioContext;
})
| null;
const Ctor = view?.AudioContext ?? view?.webkitAudioContext;
return Ctor ? new Ctor() : null;
});
const toneGenerator = toneGeneratorFactory({
context: () => context!,
destination: () => masterGain!,
});
// Single source of truth for `status`: the context's own statechange event.
// Browser-initiated transitions the page never requested (Safari interrupt,
// OS suspend) flow through here too, and because resume()/suspend() land
// their result via this listener there is no manual status write left to
// race - a stale resume resolution cannot clobber a later suspend.
const onStateChange = (): void => {
if (context) {
status.set(mapAudioStatus(context.state));
}
};
function resumeIfArmed(): void {
if (context && gate.armed() && isSuspendedLike(context.state)) {
void (context as AudioContext).resume().catch(() => undefined);
}
}
function ensureContext(): BaseAudioContext | null {
if (context) {
return context;
}
const created = contextFactory();
if (!created) {
status.set('unsupported');
return null;
}
context = created;
masterGain = created.createGain();
masterGain.gain.value = volume();
masterGain.connect(created.destination);
// Born in its current state - no statechange fires for the initial value,
// so seed it directly; every later transition arrives via onStateChange.
status.set(mapAudioStatus(created.state));
created.addEventListener('statechange', onStateChange);
resumeIfArmed();
return context;
}
function blocked(): boolean {
return muted() || (config.respectReducedMotion && reducedMotion()) || !gate.armed();
}
// Volume is communicated to the master node reactively - the single writer is
// setVolume(), but binding through an effect keeps the node in sync from any
// future volume source too.
effect(() => {
const v = volume();
if (masterGain) {
masterGain.gain.value = v;
}
});
// Suspend on tab hide (frees the audio hardware); resume on return if armed.
const onVisibility = (): void => {
if (!context) {
return;
}
if (doc.hidden) {
if (context.state === 'running') {
void (context as AudioContext).suspend().catch(() => undefined);
}
} else {
resumeIfArmed();
}
};
doc.addEventListener('visibilitychange', onVisibility);
destroyRef.onDestroy(() => {
doc.removeEventListener('visibilitychange', onVisibility);
context?.removeEventListener('statechange', onStateChange);
// close() rejects on an already-closed context; swallow it so a double
// destroy does not surface an unhandled rejection.
const closing = (context as AudioContext | null)?.close?.();
void closing?.then(
() => undefined,
() => undefined,
);
});
return {
play(name, scale) {
if (blocked()) {
return;
}
if (!ensureContext()) {
return;
}
resumeIfArmed();
if (!debouncer.shouldFire(name)) {
return;
}
const earcon = earcons.get(name);
if (!earcon) {
if (isDevMode() && !warnedUnknownEarcons.has(name)) {
warnedUnknownEarcons.add(name);
console.warn(
`[cngxAudio] Unknown earcon "${name}". Register it via withEarcons({...}) or engine.register(name, config).`,
);
}
return;
}
toneGenerator.sequence(scaleSteps(earcon.sequence, scale));
lastPlayed.set(name);
},
tone(freq, durationMs, opts, scale) {
if (blocked() || !ensureContext()) {
return;
}
resumeIfArmed();
toneGenerator.tone(
freq,
durationMs,
scale === undefined ? opts : { ...opts, gain: scaleGain(opts?.gain, scale) },
);
},
sequence(steps, scale) {
if (blocked() || !ensureContext()) {
return;
}
resumeIfArmed();
toneGenerator.sequence(scaleSteps(steps, scale));
},
register(name, earconConfig) {
earcons.set(name, earconConfig);
},
armAutoplay() {
gate.arm();
resumeIfArmed();
},
setMuted(next) {
muted.set(next);
},
setVolume(next) {
volume.set(clampVolume(next));
},
muted: muted.asReadonly(),
volume: volume.asReadonly(),
status: status.asReadonly(),
lastPlayed: lastPlayed.asReadonly(),
};
}