From 886f2df7678f7deff53cb956eccd27e3de2cbf19 Mon Sep 17 00:00:00 2001 From: Saiful Islam Date: Thu, 30 Apr 2026 14:00:33 +0600 Subject: [PATCH] fix(engine): defer AudioContext creation to first user gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructing the AudioContext inside init() trips Chrome's autoplay-policy warning ("AudioContext was not allowed to start") because init() typically runs at module load, outside any user gesture. The context was then born suspended, which silently dropped the very first sound — most visibly the hover sound on pointerenter, which is not itself a qualifying gesture. Move construction into the existing gesture-unlock path so the context is created inside a real pointerdown / touchstart / mousedown / keydown. The context comes up running and the next pointerenter plays as expected. playSound bails silently when no context exists yet (no warning, no node). Closes #2 --- README.md | 3 ++- src/engine.ts | 54 +++++++++++++++++----------------------- src/tests/engine.test.ts | 12 ++++++--- src/tests/tiks.test.ts | 4 ++- 4 files changed, 36 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 0a04c57..ceff283 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ npm install @rexa-developer/tiks ```ts import { tiks } from '@rexa-developer/tiks' -// Initialize (call on first user gesture for browser autoplay policy) +// Initialize (safe to call at any time — the AudioContext is created on the +// first real user gesture, so this never triggers an autoplay-policy warning) tiks.init() // Play sounds diff --git a/src/engine.ts b/src/engine.ts index 01c844e..0eaafba 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -19,14 +19,8 @@ class AudioEngine { init(options?: TiksOptions) { if (!AudioCtxCtor) return - if (!this.ctx || this.ctx.state === 'closed') { - this.ctx = new AudioCtxCtor() - this.masterGain = this.ctx.createGain() - this.masterGain.connect(this.ctx.destination) - } - if (options?.volume !== undefined) this._volume = Math.max(0, Math.min(1, options.volume)) - this.masterGain!.gain.value = this._volume + if (this.masterGain) this.masterGain.gain.value = this._volume if (options?.muted) this._muted = true @@ -37,10 +31,16 @@ class AudioEngine { this.bindLifecycle() this.bindGestureUnlock() + } - if (this.ctx.state === 'suspended') { - this.ctx.resume().catch(() => {}) - } + private createContext(): AudioContext | null { + if (!AudioCtxCtor) return null + if (this.ctx && this.ctx.state !== 'closed') return this.ctx + this.ctx = new AudioCtxCtor() + this.masterGain = this.ctx.createGain() + this.masterGain.connect(this.ctx.destination) + this.masterGain.gain.value = this._volume + return this.ctx } private bindLifecycle() { @@ -58,9 +58,11 @@ class AudioEngine { window.addEventListener('pageshow', resume) } - // iOS Safari keeps AudioContexts locked until a node is actually started - // inside a user gesture. Attach one listener that fires a silent 1-sample - // buffer on the next gesture and unbinds itself once ctx.state === 'running'. + // Construct the AudioContext lazily on the first qualifying user gesture. + // Creating it eagerly (e.g. inside init() during page load) trips Chrome's + // "AudioContext was not allowed to start" warning and leaves the context + // suspended until a gesture arrives. By deferring construction, the context + // is born in 'running' state and the very next pointerenter / hover plays. private bindGestureUnlock() { if (this._unlockBound) return if (typeof document === 'undefined') return @@ -68,16 +70,17 @@ class AudioEngine { let unlocking = false const unlock = () => { - const c = this.ctx - if (!c) return if (unlocking) return unlocking = true + const c = this.createContext() + if (!c) return if (c.state === 'suspended') { c.resume().then( () => { if (c.state === 'running') this._unlockTeardown?.() }, () => { unlocking = false }, ) } + // iOS Safari additionally needs a node started inside the gesture. try { const src = c.createBufferSource() src.buffer = c.createBuffer(1, 1, 22050) @@ -107,28 +110,17 @@ class AudioEngine { return this.masterGain } - private ensureContext(): AudioContext | null { - if (!AudioCtxCtor) return null - if (!this.ctx || this.ctx.state === 'closed') { - this.ctx = new AudioCtxCtor() - this.masterGain = this.ctx.createGain() - this.masterGain.connect(this.ctx.destination) - this.masterGain.gain.value = this._volume - } - return this.ctx - } - playSound(generator: SoundGenerator, theme: TiksTheme) { if (this._muted) return - const ctx = this.ensureContext() + // No context yet means no gesture has happened. Bail silently — a hover + // sound triggered before any user interaction can't play under autoplay + // policy anyway, and constructing a context here would re-introduce the + // "AudioContext was not allowed to start" warning. + const ctx = this.ctx if (!ctx || !this.masterGain) return if (ctx.state === 'suspended') { ctx.resume().catch(() => {}) - // On Safari, starting nodes on a suspended context is silent. Bail - // instead of faking playback — the gesture unlock handler is already - // installed and will flip the context to 'running' for the next call. - // (On Chrome, resume() can flip state synchronously; re-read to check.) if ((ctx.state as AudioContextState) !== 'running') return } diff --git a/src/tests/engine.test.ts b/src/tests/engine.test.ts index 917d1fb..0712947 100644 --- a/src/tests/engine.test.ts +++ b/src/tests/engine.test.ts @@ -8,18 +8,22 @@ const testTheme = { } describe('AudioEngine', () => { - it('creates AudioContext and GainNode on init', () => { + it('init does not create AudioContext until first user gesture', () => { audioEngine.init() - expect(audioEngine.getContext()).toBeTruthy() - expect(audioEngine.getMasterGain()).toBeTruthy() + expect(audioEngine.getContext()).toBeNull() + expect(audioEngine.getMasterGain()).toBeNull() }) it('sets default volume to 0.3', () => { expect(audioEngine.getVolume()).toBe(0.3) }) - it('sets custom volume from options', () => { + it('applies init volume to gain node once context exists', () => { audioEngine.init({ volume: 0.7 }) + // Force creation without dispatching a real gesture, so the gesture-unlock + // listener stays bound for the dedicated tests below. + ;(audioEngine as unknown as { createContext: () => AudioContext }).createContext() + expect(audioEngine.getContext()).toBeTruthy() expect(audioEngine.getMasterGain()!.gain.value).toBeCloseTo(0.7) audioEngine.setVolume(0.3) }) diff --git a/src/tests/tiks.test.ts b/src/tests/tiks.test.ts index e217f18..20129f8 100644 --- a/src/tests/tiks.test.ts +++ b/src/tests/tiks.test.ts @@ -3,8 +3,10 @@ import { tiks } from '../tiks' import { audioEngine } from '../engine' describe('TiksEngine', () => { - it('init creates audio context', () => { + it('init followed by a user gesture creates audio context', () => { tiks.init() + expect(audioEngine.getContext()).toBeNull() + document.dispatchEvent(new Event('pointerdown')) expect(audioEngine.getContext()).toBeTruthy() })