Skip to main content
cngx-src documentation

createAudioEngine

Variablecommon/audio

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#

CngxAudioEngineFactory

Default 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(),
  };
}