Environment
@galacean/engine: 2.0.0-alpha.41
- Browser: Safari on iPhone
- Audio assets are fully preloaded through
engine.resourceManager.load<AudioClip>() before the playback calls below
Problem
Calling AudioSource.play() before the first user gesture can prevent a later AudioSource.play() made synchronously inside a real Canvas gesture from producing any audio on iOS.
This is not limited to the early sound being dropped. The later gesture playback is also blocked.
Minimal reproduction
const [openingClip, bgmClip] = await Promise.all([
engine.resourceManager.load<AudioClip>(openingUrl),
engine.resourceManager.load<AudioClip>(bgmUrl)
]);
const opening = entity.addComponent(AudioSource);
opening.playOnEnabled = false;
opening.clip = openingClip;
const bgm = entity.addComponent(AudioSource);
bgm.playOnEnabled = false;
bgm.loop = true;
bgm.clip = bgmClip;
// 1. Runs after loading, but before any user interaction.
opening.play();
// 2. Runs synchronously inside the first real Canvas gesture.
canvas.addEventListener("touchend", () => {
bgm.play();
}, { capture: true, passive: true, once: true });
Reproduction steps
- Open the page in Safari on an iPhone with a fresh page load.
- Do not touch the screen during loading.
- Let the code call
opening.play() before user interaction.
- After entering the battle scene, tap the Canvas once.
- Observe that BGM and subsequent sound effects remain silent.
A/B result
- With the pre-gesture
opening.play() call: tapping the Canvas later does not recover audio.
- If the pre-gesture call is skipped:
bgm.play() inside the Canvas touchend handler works, and subsequent sound effects work normally.
- Adding an application-side gate that prevents every
AudioSource.play() before the first gesture also makes audio work.
- Explicit application-side
AudioManager.resume() is not required when the clip is preloaded and AudioSource.play() runs directly inside the gesture.
This A/B was repeated in the same game and on the same device; the only changed condition was whether an AudioSource.play() occurred before the first gesture.
Suspected root cause
AudioManager.resume() globally coalesces calls with _resumePromise:
return (AudioManager._resumePromise ??= AudioManager.getContext()
.resume()
.then(() => {
AudioManager._needsUserGestureResume = false;
})
.finally(() => {
AudioManager._resumePromise = null;
}));
On iOS, the non-gesture AudioContext.resume() can remain pending. When AudioSource.play() is later called inside a valid user gesture, it reuses that old pending Promise instead of issuing a fresh AudioContext.resume() in the current gesture. Consequently, _pendingPlay never reaches _startPlayback().
Expected behavior
A failed or indefinitely pending resume attempt initiated outside user activation must not prevent a later AudioSource.play() inside a valid user gesture from making a fresh recovery attempt.
The early one-shot sound may be dropped; later gesture playback and subsequent audio must recover.
Fix direction / regression requirement
The gesture playback path should be able to supersede a stale non-gesture resume attempt while preserving the existing coalescing used by iOS interruption recovery. If _resumePromise can be replaced, its cleanup should use promise identity so an older Promise cannot clear a newer one.
A regression test should cover:
- The first
AudioContext.resume() initiated outside user activation remains pending.
- A later playback request under user activation makes a new effective resume attempt.
- The later
AudioSource starts, while the early one-shot sound is not replayed out of sync.
Environment
@galacean/engine:2.0.0-alpha.41engine.resourceManager.load<AudioClip>()before the playback calls belowProblem
Calling
AudioSource.play()before the first user gesture can prevent a laterAudioSource.play()made synchronously inside a real Canvas gesture from producing any audio on iOS.This is not limited to the early sound being dropped. The later gesture playback is also blocked.
Minimal reproduction
Reproduction steps
opening.play()before user interaction.A/B result
opening.play()call: tapping the Canvas later does not recover audio.bgm.play()inside the Canvastouchendhandler works, and subsequent sound effects work normally.AudioSource.play()before the first gesture also makes audio work.AudioManager.resume()is not required when the clip is preloaded andAudioSource.play()runs directly inside the gesture.This A/B was repeated in the same game and on the same device; the only changed condition was whether an
AudioSource.play()occurred before the first gesture.Suspected root cause
AudioManager.resume()globally coalesces calls with_resumePromise:On iOS, the non-gesture
AudioContext.resume()can remain pending. WhenAudioSource.play()is later called inside a valid user gesture, it reuses that old pending Promise instead of issuing a freshAudioContext.resume()in the current gesture. Consequently,_pendingPlaynever reaches_startPlayback().Expected behavior
A failed or indefinitely pending resume attempt initiated outside user activation must not prevent a later
AudioSource.play()inside a valid user gesture from making a fresh recovery attempt.The early one-shot sound may be dropped; later gesture playback and subsequent audio must recover.
Fix direction / regression requirement
The gesture playback path should be able to supersede a stale non-gesture resume attempt while preserving the existing coalescing used by iOS interruption recovery. If
_resumePromisecan be replaced, its cleanup should use promise identity so an older Promise cannot clear a newer one.A regression test should cover:
AudioContext.resume()initiated outside user activation remains pending.AudioSourcestarts, while the early one-shot sound is not replayed out of sync.