Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 23 additions & 31 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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() {
Expand All @@ -58,26 +58,29 @@ 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
this._unlockBound = true

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)
Expand Down Expand Up @@ -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
}

Expand Down
12 changes: 8 additions & 4 deletions src/tests/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
4 changes: 3 additions & 1 deletion src/tests/tiks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
Loading