Skip to content

[Audio][iOS] Pre-gesture AudioSource.play() can block later gesture playback #3086

Description

@GuoLei1990

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

  1. Open the page in Safari on an iPhone with a fresh page load.
  2. Do not touch the screen during loading.
  3. Let the code call opening.play() before user interaction.
  4. After entering the battle scene, tap the Canvas once.
  5. 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:

  1. The first AudioContext.resume() initiated outside user activation remains pending.
  2. A later playback request under user activation makes a new effective resume attempt.
  3. The later AudioSource starts, while the early one-shot sound is not replayed out of sync.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions