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 }),
);
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!,
});
function resumeIfArmed(): void {
if (context && gate.armed() && context.state === 'suspended') {
void (context as AudioContext).resume().then(
() => status.set('running'),
() => 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);
status.set(created.state as AudioStatus);
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().then(
() => status.set('suspended'),
() => undefined,
);
}
} else {
resumeIfArmed();
}
};
doc.addEventListener('visibilitychange', onVisibility);
destroyRef.onDestroy(() => {
doc.removeEventListener('visibilitychange', onVisibility);
// 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()) {
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(),
};
}