Skip to content

fix(audio): harden playback lifecycle#3026

Open
luzhuang wants to merge 16 commits into
dev/2.0from
fix/audio-shaderlab-split
Open

fix(audio): harden playback lifecycle#3026
luzhuang wants to merge 16 commits into
dev/2.0from
fix/audio-shaderlab-split

Conversation

@luzhuang

@luzhuang luzhuang commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Keep AudioSource.play() as a one-shot attempt: autoplay-blocked, hidden, or still-suspended resolutions are dropped instead of replayed on a later unrelated gesture.
  • Add an internal playback gate so AudioSource.play() cannot start a source while document.hidden or AudioManager hidden state is true, even if the raw AudioContext.state still reports running before async suspend settles.
  • Keep AudioManager.isAudioContextRunning() as a raw WebAudio state check; lifecycle permission stays in AudioManager._canStartPlayback() and the hidden/show handlers.
  • Harden AudioManager context lifecycle handling across visibilitychange, pagehide, pageshow, external non-running state changes, caller suspend, and foreground recovery.
  • Avoid creating an AudioContext from AudioManager.suspend(), and prevent caller-controlled suspend from being auto-resumed by gesture or foreground recovery.
  • Preserve the iOS WKWebView zombie-audio foreground recovery path with a delayed suspend/resume retry.
  • Add focused audio coverage for autoplay-blocked drop behavior, caller suspend, hidden/page lifecycle, foreground resume failure, hidden-state races, stopped-source behavior, and hidden playback fast-path gating.

Verification

  • pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm exec cross-env HEADLESS=true vitest run tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm exec eslint tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • git diff --check HEAD
  • GitHub Actions on 54ef72e8d: lint, codecov, codecov patch/project, ubuntu/windows/macos build, and e2e 1/4-4/4 are all green.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

AudioManager gains pending-source tracking, caller-vs-browser suspension separation, and an iOS zombie-fix delayed-resume path with visibility gating. AudioSource.play() registers itself as pending when the context is not running, and new _resumePendingPlayback()/_cancelPendingPlayback() helpers handle deferred starts. A new Vitest suite (548 lines) validates all lifecycle, gesture, and race scenarios.

Changes

Audio Pending Playback and Lifecycle Recovery

Layer / File(s) Summary
PendingAudioSource contract and AudioManager internal state
packages/core/src/audio/AudioManager.ts
Adds PendingAudioSource internal type; extends AudioManager with _hidden, _foregroundResumeTimer, _suspendedByCaller, _pendingSources, and _needsUserGestureResume fields; updates suspend() to clear timers, remove gesture listeners, mark caller-suspension, and suspend the context.
AudioManager resume() rewrite and getContext() lifecycle binding
packages/core/src/audio/AudioManager.ts
Rewrites resume() to short-circuit when context is already running (draining pending sources immediately), otherwise resumes context then drains pending sources; removes _resumePromise deduplication; adds _registerPendingSource/_unregisterPendingSource helpers; getContext() initializes _hidden, attaches onstatechange, and lazily binds visibilitychange, pagehide, pageshow, and gesture listeners once.
AudioManager event-driven state machine
packages/core/src/audio/AudioManager.ts
Adds _onContextStateChange, _onHidden, _onShown (iOS zombie-fix), _resumePendingSources, and _resumeAfterInterruption handlers; wires pointerup/click gesture listeners; implements delayed suspend→resume foreground recovery with hidden-state guard.
AudioSource play/stop/pause pending integration
packages/core/src/audio/AudioSource.ts
play() sets _pendingPlay, registers with AudioManager, and calls AudioManager.resume().catch() when context is not running. stop() and pause() call _cancelPendingPlayback() before resetting node and timing state.
AudioSource resumption and lifecycle helpers
packages/core/src/audio/AudioSource.ts
Adds _resumePendingPlayback() to re-enter playback if _pendingPlay is still set; hardens _startPlayback() with boolean-return _initSourceNode(); makes _clearSourceNode() null-safe; centralizes cleanup in _cancelPendingPlayback().
Test infrastructure: mocks, helpers, and suite wiring
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
Adds MockAudioContext with queued resume-result sequences; provides flushAsync(), createAudioSource(), resetAudioManagerState(), setDocumentHidden(), and captureScheduledTimers(); wires beforeEach/afterEach for full isolation.
Tests: autoplay blocking, pending playback, and gesture unlock
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
Verifies pending playback retry after autoplay-blocked gesture unlock; stop() before gesture cancels pending; resume() clears _needsUserGestureResume; external context interruption becomes gesture-retryable.
Tests: visibility/pagehide/pageshow lifecycle and zombie-fix
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
Verifies hidden suspend, iOS zombie-fix delayed resume, suppression of timer if re-hidden, hidden gating of resume(), resume/hide race, hidden blocking of pending-source drain, shown-without-hide no-op, pagehide/pageshow, failed foreground resume setting gesture flag, and retry on later gesture.
Tests: caller-controlled suspension
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
Verifies caller suspend() blocks gesture auto-resume; pending playback remains retryable after suspend() with blocked autoplay, cleared on successful gesture.
Tests: _playingCount balance and stopped-source no-restart
tests/src/core/audio/AudioSourcePendingPlayback.test.ts
Verifies _playingCount stays balanced across play/pause/stop/onended; stopped sources are not restarted across a hide/show cycle.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 Hop, hop, the context sleeps,
A pending source its promise keeps.
On zombie show, we suspend then wait,
A timer guards the waking state.
Gesture taps unlock the flow,
And hidden pages never go! 🎵

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(audio): harden playback lifecycle' accurately reflects the main change: hardening the pending playback lifecycle in the audio module through refactored state management and resumption logic in AudioManager and AudioSource.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audio-shaderlab-split

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/audio/AudioManager.ts`:
- Around line 39-56: AudioManager.resume() currently resumes and replays
pending/interrupted sources even when the document is hidden because it ignores
the internal _hidden flag; update resume() (and the similar branch around the
later block noted) to check AudioManager._hidden and, if true, avoid calling
_resumePendingSources() and _resumeInterruptedSources() (but still clear
foreground restore flags as appropriate), deferring actual source resumes until
_onShown() runs; reference the AudioManager.resume method, the _hidden field,
_onShown, _resumePendingSources, _resumeInterruptedSources, and
_needsUserGestureResume when making the change.
- Around line 121-129: The on-state-change handler _onContextStateChange
currently only handles transitions into "running"; update it to also detect
transitions out of "running" and move currently playing sources into the
interrupted set so they can be resumed later. Specifically, when
AudioManager._context?.state changes from "running" to a non-running state (and
not via AudioManager.suspend()), iterate over AudioManager._playingSources and
invoke each source's _suspendPlaybackForInterruption (or mark them into
AudioManager._interruptedSources), removing them from _playingSources; keep the
existing resume logic that calls _resumePendingSources and
_resumeInterruptedSources when state becomes "running" so
_resumeInterruptedPlayback can replay them. Ensure this logic lives inside
_onContextStateChange and references AudioManager._playingSources,
AudioManager._interruptedSources, AudioSource._suspendPlaybackForInterruption
and AudioSource._resumeInterruptedPlayback accordingly.

In `@packages/loader/src/AudioLoader.ts`:
- Line 12: The decorator on AudioLoader (`@resourceLoader`(AssetType.Audio,
["mp3", "ogg", "wav", "audio", "m4a", "aac", "flac"])) includes a non-standard
"audio" extension; either remove "audio" from that extensions array to avoid
incorrect mapping, or if "audio" is a deliberate alias add documentation and an
example asset demonstrating its usage and update any relevant docs/metadata
accordingly so the intent is clear. Ensure the change is made on the
`@resourceLoader` call associated with the AudioLoader class and keep the
remaining extensions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ea1c626e-c43c-46b2-8aa6-d3b222b79624

📥 Commits

Reviewing files that changed from the base of the PR and between de75496 and 442552c.

📒 Files selected for processing (5)
  • packages/core/src/audio/AudioManager.ts
  • packages/core/src/audio/AudioSource.ts
  • packages/loader/src/AudioLoader.ts
  • tests/src/core/audio/AudioSource.test.ts
  • tests/src/core/audio/AudioSourcePendingPlayback.test.ts

Comment thread packages/core/src/audio/AudioManager.ts
Comment on lines +121 to +129
private static _onContextStateChange(): void {
if (AudioManager._context?.state === "running") {
if (AudioManager._hidden || AudioManager._needsUserGestureResume) {
return;
}
AudioManager._needsUserGestureResume = false;
AudioManager._resumePendingSources();
AudioManager._resumeInterruptedSources();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle non-running context transitions here too.

Line 121 only reacts when the context comes back to "running". If the browser moves the shared AudioContext out of running without going through AudioManager.suspend(), _playingSources never gets converted into _interruptedSources, so Line 128 has nothing to replay when the context recovers.

Cross-file evidence: packages/core/src/audio/AudioSource.ts:242-293 provides _suspendPlaybackForInterruption() / _resumeInterruptedPlayback(), but this file only drives the suspend half from AudioManager.suspend(), not from onstatechange.

Suggested fix
 private static _onContextStateChange(): void {
-  if (AudioManager._context?.state === "running") {
-    if (AudioManager._hidden || AudioManager._needsUserGestureResume) {
-      return;
-    }
-    AudioManager._needsUserGestureResume = false;
-    AudioManager._resumePendingSources();
-    AudioManager._resumeInterruptedSources();
-  }
+  const state = AudioManager._context?.state;
+  if (state === "interrupted" || state === "suspended") {
+    AudioManager._suspendActiveSourcesForInterruption();
+    return;
+  }
+
+  if (state === "running") {
+    if (AudioManager._hidden || AudioManager._needsUserGestureResume) {
+      return;
+    }
+    AudioManager._needsUserGestureResume = false;
+    AudioManager._resumePendingSources();
+    AudioManager._resumeInterruptedSources();
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/audio/AudioManager.ts` around lines 121 - 129, The
on-state-change handler _onContextStateChange currently only handles transitions
into "running"; update it to also detect transitions out of "running" and move
currently playing sources into the interrupted set so they can be resumed later.
Specifically, when AudioManager._context?.state changes from "running" to a
non-running state (and not via AudioManager.suspend()), iterate over
AudioManager._playingSources and invoke each source's
_suspendPlaybackForInterruption (or mark them into
AudioManager._interruptedSources), removing them from _playingSources; keep the
existing resume logic that calls _resumePendingSources and
_resumeInterruptedSources when state becomes "running" so
_resumeInterruptedPlayback can replay them. Ensure this logic lives inside
_onContextStateChange and references AudioManager._playingSources,
AudioManager._interruptedSources, AudioSource._suspendPlaybackForInterruption
and AudioSource._resumeInterruptedPlayback accordingly.

Comment thread packages/loader/src/AudioLoader.ts Outdated
GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 mentioned this pull request Jun 14, 2026
3 tasks
GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 marked this pull request as draft June 15, 2026 02:49
…end/resume

- Remove interrupted source mechanism (no source node destruction on hide)
- Trust context.suspend/resume to keep nodes alive (Phaser pattern)
- Add onstatechange non-running branch for external interruption recovery
- Fix resume() to create context for pre-unlock use case
- Reduce gesture listeners to pointerup+click, remove after unlock
- Simplify iOS zombie fix to suspend→100ms→resume
- Fix AudioSource.test.ts import to use @galacean/engine (CI compat)
@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.87571% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.22%. Comparing base (de75496) to head (54ef72e).
⚠️ Report is 5 commits behind head on dev/2.0.

Files with missing lines Patch % Lines
packages/core/src/audio/AudioManager.ts 90.44% 13 Missing ⚠️
packages/core/src/audio/AudioSource.ts 70.73% 12 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3026      +/-   ##
===========================================
+ Coverage    77.48%   79.22%   +1.74%     
===========================================
  Files          914      914              
  Lines       101783   101799      +16     
  Branches     10430    11273     +843     
===========================================
+ Hits         78862    80652    +1790     
+ Misses       22738    20962    -1776     
- Partials       183      185       +2     
Flag Coverage Δ
unittests 79.22% <85.87%> (+1.74%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

luzhuang added 3 commits June 15, 2026 15:09
…ension

- Add _hidden check in _resumePendingSources to prevent source replay while hidden
- Initialize _hidden from document.hidden when context is created
- Remove ".audio" from AudioLoader extensions (Editor doesn't export .audio URLs)
…n CI

Mock AudioContext's suspend/resume were triggering onstatechange synchronously,
causing recursive call stacks when event dispatch + state change + gesture
listener interact in the same synchronous frame during CI browser tests.
@luzhuang luzhuang marked this pull request as ready for review June 15, 2026 08:55

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/src/core/audio/AudioSourcePendingPlayback.test.ts (1)

45-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Queued resumeResultQueue success path does not emulate real resume state transitions.

On Line 47–Line 49, returning a queued Promise directly bypasses state = "running" and onstatechange dispatch, unlike the normal success path. This can make lifecycle tests observe inconsistent behavior depending on how queue entries are authored.

Suggested fix
   resume(): Promise<void> {
     const queuedResult = MockAudioContext.resumeResultQueue?.shift();
     if (queuedResult instanceof Promise) {
-      return queuedResult;
+      return queuedResult.then(() => {
+        this.state = "running";
+        const cb = this.onstatechange;
+        cb?.();
+      });
     }
     if (queuedResult instanceof Error) {
       return Promise.reject(queuedResult);
     }
     if (!MockAudioContext.shouldResumeSucceed) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts` around lines 45 -
49, The resume() method in the MockAudioContext class returns a queued Promise
directly without ensuring state transitions are performed. When
resumeResultQueue contains a Promise entry, modify the code to chain a .then()
handler to that Promise so it sets state to "running" and dispatches the
onstatechange event after the Promise resolves, matching the behavior of the
normal success path and ensuring consistent lifecycle state transitions
regardless of how queue entries are authored.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts`:
- Around line 45-49: The resume() method in the MockAudioContext class returns a
queued Promise directly without ensuring state transitions are performed. When
resumeResultQueue contains a Promise entry, modify the code to chain a .then()
handler to that Promise so it sets state to "running" and dispatches the
onstatechange event after the Promise resolves, matching the behavior of the
normal success path and ensuring consistent lifecycle state transitions
regardless of how queue entries are authored.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2854a840-cfa8-4903-8578-d7e2bfd2eb41

📥 Commits

Reviewing files that changed from the base of the PR and between 442552c and bf34eb5.

📒 Files selected for processing (4)
  • notes/audio/2026-06-15-audio-context-lifecycle-ci.md
  • packages/core/src/audio/AudioManager.ts
  • packages/core/src/audio/AudioSource.ts
  • tests/src/core/audio/AudioSourcePendingPlayback.test.ts
💤 Files with no reviewable changes (1)
  • packages/core/src/audio/AudioSource.ts
✅ Files skipped from review due to trivial changes (1)
  • notes/audio/2026-06-15-audio-context-lifecycle-ci.md

GuoLei1990

This comment was marked as outdated.

@luzhuang luzhuang changed the title fix(audio): split shaderlab audio fixes fix(audio): harden pending playback lifecycle Jun 15, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/src/core/audio/AudioSourcePendingPlayback.test.ts (2)

121-127: ⚡ Quick win

Model timer cancellation in the test harness.

captureScheduledTimers() records callbacks but does not track clearTimeout, so canceled timers remain manually invokable in tests. That weakens verification of _clearForegroundResumeTimer behavior.

Suggested patch
 function captureScheduledTimers(): Array<() => void> {
   const scheduledTimers: Array<() => void> = [];
-  vi.spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => {
-    scheduledTimers.push(handler as () => void);
-    return scheduledTimers.length as any;
-  });
+  let nextId = 1;
+  const canceled = new Set<number>();
+
+  vi.spyOn(globalThis, "setTimeout").mockImplementation((handler: TimerHandler) => {
+    const id = nextId++;
+    const cb = handler as () => void;
+    scheduledTimers.push(() => {
+      if (!canceled.has(id)) cb();
+    });
+    return id as any;
+  });
+
+  vi.spyOn(globalThis, "clearTimeout").mockImplementation((id?: number) => {
+    if (typeof id === "number") canceled.add(id);
+  });
   return scheduledTimers;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts` around lines 121 -
127, The `captureScheduledTimers()` function captures scheduled callbacks but
does not track which timers are cancelled via `clearTimeout`, allowing cancelled
timers to remain invokable in the test. Extend the function to also spy on and
mock `globalThis.clearTimeout`, tracking cancelled timer IDs and removing the
corresponding callbacks from the scheduledTimers array when clearTimeout is
called. This ensures that cancelled timers cannot be manually invoked and
properly verifies the behavior of `_clearForegroundResumeTimer`.

130-149: ⚡ Quick win

Make document.hidden restoration failure-safe.

The helper depends on manual restore() at each callsite. If a test throws before restoration, global document.hidden can leak into later tests and cascade failures.

Suggested pattern
 function mockDocumentHidden(initialHidden: boolean): { set(hidden: boolean): void; restore(): void } {
   const ownDescriptor = Object.getOwnPropertyDescriptor(document, "hidden");
   let hidden = initialHidden;
   Object.defineProperty(document, "hidden", {
     configurable: true,
     get: () => hidden
   });
   return {
     set(value: boolean) {
       hidden = value;
     },
     restore() {
       if (ownDescriptor) {
         Object.defineProperty(document, "hidden", ownDescriptor);
       } else {
         delete (document as any).hidden;
       }
     }
   };
 }
+
+async function withMockedDocumentHidden(
+  initialHidden: boolean,
+  run: (ctl: { set(hidden: boolean): void }) => Promise<void> | void
+): Promise<void> {
+  const ctl = mockDocumentHidden(initialHidden);
+  try {
+    await run({ set: ctl.set });
+  } finally {
+    ctl.restore();
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts` around lines 130 -
149, The mockDocumentHidden function requires manual restoration via restore()
calls, which can be skipped if a test throws an exception, causing
document.hidden to leak into subsequent tests. Refactor the approach to
automatically restore document.hidden after each test by leveraging Jest's
afterEach hook or wrapping the mock setup in a helper that guarantees cleanup
regardless of test success or failure, ensuring the original document descriptor
is always restored.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/src/core/audio/AudioSourcePendingPlayback.test.ts`:
- Around line 121-127: The `captureScheduledTimers()` function captures
scheduled callbacks but does not track which timers are cancelled via
`clearTimeout`, allowing cancelled timers to remain invokable in the test.
Extend the function to also spy on and mock `globalThis.clearTimeout`, tracking
cancelled timer IDs and removing the corresponding callbacks from the
scheduledTimers array when clearTimeout is called. This ensures that cancelled
timers cannot be manually invoked and properly verifies the behavior of
`_clearForegroundResumeTimer`.
- Around line 130-149: The mockDocumentHidden function requires manual
restoration via restore() calls, which can be skipped if a test throws an
exception, causing document.hidden to leak into subsequent tests. Refactor the
approach to automatically restore document.hidden after each test by leveraging
Jest's afterEach hook or wrapping the mock setup in a helper that guarantees
cleanup regardless of test success or failure, ensuring the original document
descriptor is always restored.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3fae02eb-37c8-470c-992a-95a83d45f534

📥 Commits

Reviewing files that changed from the base of the PR and between 2773cd3 and 560b77b.

📒 Files selected for processing (1)
  • tests/src/core/audio/AudioSourcePendingPlayback.test.ts

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

@luzhuang

Copy link
Copy Markdown
Contributor Author

Addressed in 573f21d01.

  • Removed the global _pendingSources queue and the internal source replay hooks.
  • Restored AudioSource.play() to one-shot semantics: it attempts AudioManager.resume() for the current play call, starts only if the context is actually running afterward, and drops the request on autoplay rejection or hidden/suspended resolution.
  • Kept the AudioManager context lifecycle fixes for hidden/pagehide/pageshow, iOS zombie-audio foreground resume, explicit caller suspend, and gesture retry for context recovery.
  • Updated coverage to assert that autoplay-blocked, hidden, and explicit-suspend blocked play calls are not replayed by a later unrelated gesture.

Verification:

  • pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm exec cross-env HEADLESS=true vitest run --coverage tests/src/core/PolyfillAudioContext.test.ts tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm -F @galacean/engine-core run b:types
  • pnpm exec eslint packages/core/src/audio/AudioManager.ts packages/core/src/audio/AudioSource.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • git diff --check HEAD

GitHub Actions for the new head are running.

@luzhuang luzhuang changed the title fix(audio): harden pending playback lifecycle fix(audio): harden AudioContext lifecycle recovery Jun 16, 2026
@luzhuang

Copy link
Copy Markdown
Contributor Author

Looks good from my side after removing the cross-gesture pending replay mechanism.

The PR still has merge value, but the scope has changed: it is no longer a pending playback feature. It now keeps AudioSource.play() as a one-shot attempt, while hardening AudioManager's context lifecycle handling across hidden/pagehide/pageshow, explicit caller suspend, external non-running state changes, and iOS WKWebView foreground recovery.

I updated the PR title and summary to reflect the current scope and removed the stale pending-replay framing.

@luzhuang

Copy link
Copy Markdown
Contributor Author

Follow-up done in b615f01c9.

  • Renamed the PR to fix(audio): harden audio context lifecycle so it no longer implies the removed pending-replay design.
  • Caught rejection from the foreground zombie-reset context.suspend() path, matching the hidden suspend path and avoiding an unhandled rejection if that reset suspend fails.
  • Added a regression test covering rejected zombie-reset suspend while still allowing the delayed foreground resume to continue.

Verification:

  • pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm exec eslint packages/core/src/audio/AudioManager.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • git diff --check HEAD
  • GitHub Actions on b615f01c9: lint, codecov, codecov patch/project, ubuntu/windows/macos build, and e2e 1/4-4/4 are all green.

@luzhuang

Copy link
Copy Markdown
Contributor Author

Follow-up cleanup done in f05afeb14.

  • Removed the obsolete AudioManager._playingCount state now that context lifecycle no longer depends on active-source counting.
  • Stopped registering gesture listeners during initial context creation; they are now only added when a real gesture retry is needed after an external interruption or failed foreground resume.
  • Kept the lifecycle behavior unchanged: hidden/show handling, caller suspend guard, iOS foreground recovery, and gesture retry paths are still covered.

Verification:

  • pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm exec cross-env HEADLESS=true vitest run tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm -F @galacean/engine-core run b:types
  • pnpm exec eslint packages/core/src/audio/AudioManager.ts packages/core/src/audio/AudioSource.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • git diff --check HEAD
  • GitHub Actions on f05afeb14: lint, codecov, codecov patch/project, ubuntu/windows/macos build, and e2e 1/4-4/4 are all green.

@luzhuang

Copy link
Copy Markdown
Contributor Author

Addressed in e43c51cc2.

I kept AudioManager.isAudioContextRunning() as the raw AudioContext.state === "running" check and added an internal AudioManager._canStartPlayback() gate for lifecycle-aware source starts. AudioSource.play() now uses that gate both before the immediate _startPlayback() fast path and after AudioManager.resume() resolves, so hidden playback is blocked even if document.hidden is already true while the context state still temporarily reports running before async suspend settles.

I also added a regression test for that exact ordering: document.hidden === true, context still running, and AudioSource.play() must call hidden synchronization and not start playback.

Verified locally:

  • pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm exec cross-env HEADLESS=true vitest run tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • pnpm -F @galacean/engine-core run b:types
  • pnpm exec eslint packages/core/src/audio/AudioManager.ts packages/core/src/audio/AudioSource.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • git diff --check HEAD

GitHub Actions on e43c51cc2 are green: lint, codecov, ubuntu/windows/macos build, and e2e 1/4-4/4 all passed.

@luzhuang

Copy link
Copy Markdown
Contributor Author

Test cleanup done in 70b24d0ef.

I removed the old multi-source play/stop/pause/ended balance test from AudioSourcePendingPlayback.test.ts. That case was useful when the PR still carried source-counting state, but _playingCount is gone and the remaining tests now target the current lifecycle contract directly: autoplay drop, hidden/document.hidden races, caller suspend, foreground zombie reset, external interruption retry, and stopped-source behavior across hide/show.

I tried simplifying the test harness around document.hidden / timer cancellation as well, but the browser runner does not tolerate those mocks cleanly, so I left the proven harness intact instead of adding brittle test infrastructure.

Verified locally:

  • pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts => 19 passed
  • pnpm exec cross-env HEADLESS=true vitest run tests/src/core/audio/AudioSource.test.ts tests/src/core/audio/AudioSourcePendingPlayback.test.ts => 22 passed
  • pnpm exec eslint tests/src/core/audio/AudioSourcePendingPlayback.test.ts
  • git diff --check HEAD

GitHub Actions on 70b24d0ef are green: lint, codecov, ubuntu/windows/macos build, and e2e 1/4-4/4 all passed.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

总结

复审 HEAD 54ef72e8d(自上轮 e43c51cc2 起 2 个新 commit:70b24d0ef 删除过时的 _playingCount 平衡测试、54ef72e8d 删除 notes/audio/2026-06-16-hidden-playback-gate.md)。本轮是纯清理,无任何代码逻辑改动——AudioManager.ts / AudioSource.ts 与上轮字节一致(diff 仅落在 notes/ 与测试文件)。我上轮唯一的 P2 已解决,无新问题。

已闭环(本轮新提交解决)

  • [P2] notes/audio/2026-06-16-hidden-playback-gate.md scratchpad 又一次进了源码树54ef72e8d 已整文件删除。已 git ls-tree -r pr-head | grep notes/ 确认整个 notes/ 目录从 PR 树中消失(全 PR diff 现在只剩 3 个文件,全在 packages/core / tests)。关闭。
  • 过时多源 _playingCount 平衡测试70b24d0ef 删除 31 行("keeps playback state consistent across play/stop/pause/ended")。该用例断言的 AudioManager._playingCount++/--s1._onPlayEnd() 私有路径在 f05afeb14 移除 _playingCount 后已成死代码,删除正确。剩余 19 个用例直接锁定当前生命周期契约(autoplay drop / hidden 竞态 / caller suspend / 前台 zombie reset / 外部中断重试 / stopped-source 跨 hide/show),均走真实 lifecycle 事件派发,非戳私有制造场景。关闭。

第一性原理复核(沿用历史结论,逐条核对当前源码)

  • one-shot 语义 — 已对照 base dev/2.0play():PR 与 base 同为「ctx 非 running 时 resume() 一次性尝试、resolve 后 drop」的自包含语义,无全局队列、不跨手势、不累积。相对 base 还做了两处加固:fast-path 与 post-resume 都改用 _canStartPlayback()(覆盖 document.hidden===true 但 state 仍报 running 的 async-suspend 竞态)、post-resume 守卫从 !this._clip 增强为 !this._clip?._getAudioSource() || !_canStartPlayback()。是对 base 的净改进,非回归。我上轮撤回 _pendingSources 的设计反馈已完整落地。
  • play() 内 iOS 手势注释删除 — base 里 // iOS Safari requires resume() to be called within the same user gesture callback... 在本 PR 被删,但该约束并未丢失:它与所调用的 AudioManager.resume()@remarks("On iOS Safari, calling this within a user gesture... can pre-unlock audio")重复,约束随其约束的 API 一同记录。删除冗余注释合理,非问题。
  • 状态机收口、suspend/resume 保活已 start 节点、_foregroundResumeTimer 抑制 zombie-fix 自身 suspend 被误判为外部中断、interrupted/suspended non-running 分支等判例沿用,不再复述。

注释合规

无新增/改动注释。resume() / suspend() JSDoc 合规,_onShown zombie fix 双行 //(无句号、附 WebKit bug 链接、解释 为什么)为既有行。无违规。

简化建议

无。本轮就是删冗余(死测试 + scratchpad)。


CI 全绿(lint / build×3 / codecov+patch+project / e2e×4)。reviewDecision: REVIEW_REQUIRED,设计与实现层面我已无保留意见,唯一剩余的 notes 落点 P2 本轮已闭环——仅缺正式 approve。

GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 17, 2026
基于真机验证 + 业界调研,优化问题1(iOS 切后台回前台音频挂起)修复的注释与可读性:

- 注释纠正事实:删去 "Triggered in LingGuang App",改为 "Reproducible on
  plain iOS Safari, not only WKWebView"(真机在普通 iOS Safari 已复现僵尸态)。
- 注释厘清机制:bare resume() 报 running 但渲染管线不重启(无声、currentTime 冻结);
  suspend() 先清掉该状态,使后续 resume() 走完整重启路径而非被短路。
- 抽出 _zombieResumeDelay = 100 常量,并注明:100ms 为经验值(与 Phaser
  WebAudioSoundManager 一致),无 spec/厂商权威推荐;真机实测最稳;
  Promise 链 suspend().then(resume) 理论更优但偶尔失败,固定延迟未失败过。
- 手势兜底注释明确其用途(自动 resume 失败时由后续手势重试)。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 17, 2026
精简上一版过度的注释与抽象:
- 去掉 _zombieResumeDelay 常量(仅单处使用、不可配,无需抽象),内联回 100。
- 注释收敛到要点:iOS 后台 AudioContext 卡死、必须先 suspend 才能让 resume
  真正重启、bug 链接;延迟说明压成 setTimeout 上方一行(100ms 经验值、无 spec)。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 17, 2026
…em 2)

问题2:用户通过公开 API AudioManager.suspend() 主动暂停后,切后台再回前台,
问题1 的前台恢复逻辑会违背意图把它"复活"(自己又响)——因为 _onVisibilityChange
只凭 _playingCount>0 && !running 推断该恢复,而主动暂停产生的状态和系统挂起完全相同,
无法区分。真机已实锤(主动暂停→切后台→回来,音频自动恢复)。

第一性:主动暂停 vs 系统挂起的区别只存在于"谁发起的"这个意图里,无法从 AudioContext
状态反推,只能显式记录。故引入 _suspendedByCaller:
- suspend() 置 true(主动暂停)
- resume() 置 false(显式恢复 = 解除主动暂停意图)
- 两条自动恢复路径(前台恢复 _onVisibilityChange、手势 _resumeAfterInterruption)
  检查该标记,主动暂停时跳过,不自动恢复。

公开 suspend() 当前虽无引擎内部调用方,但它是 deliberately public 的用户 API
(无 @internal、有 JSDoc),其"暂停后保持暂停"的契约必须成立。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 17, 2026
…em 2)

问题2:用户通过公开 API AudioManager.suspend() 主动暂停后,切后台再回前台,
问题1 的前台恢复逻辑会违背意图把它"复活"(自己又响)——因为 _onVisibilityChange
只凭 _playingCount>0 && !running 推断该恢复,而主动暂停产生的状态和系统挂起完全相同,
无法区分。真机已实锤(主动暂停→切后台→回来,音频自动恢复)。

第一性:主动暂停 vs 系统挂起的区别只存在于"谁发起的"这个意图里,无法从 AudioContext
状态反推,只能显式记录。故引入 _suspendedByCaller:
- suspend() 置 true(主动暂停)
- resume() 置 false(显式恢复 = 解除主动暂停意图)
- 两条自动恢复路径(前台恢复 _onVisibilityChange、手势 _resumeAfterInterruption)
  检查该标记,主动暂停时跳过,不自动恢复。

公开 suspend() 当前虽无引擎内部调用方,但它是 deliberately public 的用户 API
(无 @internal、有 JSDoc),其"暂停后保持暂停"的契约必须成立。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
@luzhuang luzhuang changed the title fix(audio): harden audio context lifecycle fix(audio): harden playback lifecycle Jun 17, 2026
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 17, 2026
…i (problem 3)

真机对照实测推翻"问题3 需主动干预":
- 裸 WebAudio 接电话挂断 → iOS 自己把 context 从 interrupted 拉回 running、自愈有声;
- 引擎之前的 onstatechange 200ms 僵尸探针 + _recoverContext 在 off-gesture
  suspend→resume,反而戳进 iOS 的自愈过程、把它打成僵尸(state=running 但无声)。

故撤掉问题3 的主动干预:
- 删 context.onstatechange = _onContextStateChange 注册;
- 删 _onContextStateChange(含 200ms zombie 探针)、_recoverContext、
  _reviving / _zombieProbeTimer 字段;
- 来电中断交给 iOS 自愈,不主动碰 context。

问题1(切后台)与问题2(主动暂停)保留,且经裸页三模式(A 啥都不做/
B 只 resume/C suspend→resume)实测确认:切后台回前台 state 停在 interrupted,
直接 resume 抛 InvalidStateError,必须先 suspend 转 suspended 再 resume(C 有声),
所以问题1 的 suspend→100ms→resume 必需、不可简化。注释更新为该真因。

区分信号:回前台 state 卡 interrupted 不动 = 切后台(救);interrupted 自己
往 running 走 = 来电(别碰,iOS 自愈)。前者经 visibilitychange 进 _onVisibilityChange,
后者通常无 hidden 不触发,自然让 iOS 处理。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 17, 2026
…e reason

更新 _onVisibilityChange 的注释,说明切后台回前台必须 suspend→resume 的真因:

裸页三模式真机实测(A 啥都不做 / B 只 resume / C suspend→resume):切后台回前台
时 AudioContext 停在 "interrupted",直接 resume() 抛 InvalidStateError(B 失败),
必须先 suspend() 转成 "suspended" 才能合法 resume()(C 有声)。比原注释"zombie 重置"
更准确——根因是 interrupted 状态不能直接 resume,不只是渲染管线僵尸。

仅注释改动,逻辑不变。问题1(切后台 suspend→100ms→resume)与问题2
(_suspendedByCaller 主动暂停不被自动恢复)保留。

关于问题3(来电/Siri 中断):无需引擎改动。裸页实测来电挂断后 iOS 自己把 context
从 interrupted 拉回 running、自愈有声;引擎不应主动干预(off-gesture suspend→resume
会打断 iOS 自愈)。本分支 git 历史从未包含中断探针,对来电天然不干预,符合预期。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 18, 2026
… zombie root cause)

真机排除法定位的根因:AudioSource 构造函数(addComponent 时,通常在用户手势之前)
立即 `AudioManager.getContext().createGain()`,从而在手势前就创建了 AudioContext。
iOS 上这种"冷"(手势前)创建的 AudioContext,在小窗来电中断挂断后不会自愈,
卡成僵尸(state 报 running 但 currentTime 冻结、无声)。裸 WebAudio 在手势内
首次创建 ctx 则能自愈——唯一变量就是创建时机(真机实验 q3-bareaudio 自愈 /
q3-earlyctx 僵尸 证实)。

修复:延迟创建到首次 play(手势内)。
- 构造函数不再创建 gainNode;
- 新增 _ensureGainNode() 懒初始化,首次 _initSourceNode 时创建并应用 _volume;
- volume setter 用 _gainNode?. 守卫(未创建时只存 _volume,懒创建时再应用);
- _cloneTo 不再直接访问 target._gainNode(_volume 由字段克隆复制,懒创建时应用)。

真机验证:修复后 play 前 ctx 未创建,小窗接电话挂断自愈有声。这是从根上消除
僵尸,无需 suspend/resume 救援。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 18, 2026
… zombie root cause)

真机排除法定位的根因:AudioSource 构造函数(addComponent 时,通常在用户手势之前)
立即 `AudioManager.getContext().createGain()`,从而在手势前就创建了 AudioContext。
iOS 上这种"冷"(手势前)创建的 AudioContext,在小窗来电中断挂断后不会自愈,
卡成僵尸(state 报 running 但 currentTime 冻结、无声)。裸 WebAudio 在手势内
首次创建 ctx 则能自愈——唯一变量就是创建时机(真机实验 q3-bareaudio 自愈 /
q3-earlyctx 僵尸 证实)。

修复:延迟创建到首次 play(手势内)。
- 构造函数不再创建 gainNode;
- 新增 _ensureGainNode() 懒初始化,首次 _initSourceNode 时创建并应用 _volume;
- volume setter 用 _gainNode?. 守卫(未创建时只存 _volume,懒创建时再应用);
- _cloneTo 不再直接访问 target._gainNode(_volume 由字段克隆复制,懒创建时应用)。

真机验证:修复后 play 前 ctx 未创建,小窗接电话挂断自愈有声。这是从根上消除
僵尸,无需 suspend/resume 救援。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 18, 2026
…he playback context early

根因修复第二处(配合 AudioSource 延迟创建):AudioLoader 解码音频时用的是
AudioManager.getContext()(持久播放 ctx)。真实游戏在启动时(用户手势前)用
resourceManager.load 加载音频,这会提前创建播放 ctx —— iOS 上这种"冷"播放 ctx
小窗来电中断后不自愈(僵尸)。真机实测确认 loader 是 AudioSource 构造之外的第二个
提前创建点。

改用一个 decode-only 的 OfflineAudioContext 解码:它只渲染到内存、不占音频硬件、
在 iOS 中断机制之外,所以提前创建无害;解出的 AudioBuffer 与上下文无关,播放时由
AudioBufferSourceNode 自动重采样到播放 ctx 的率,音调/时长不变。播放 ctx 因此延迟到
首次 play(手势内)热创建,iOS 来电可自愈。

真机验证:真实 resourceManager.load 路径下,load 后播放 ctx 未创建,小窗接电话
挂断自愈、声音正常。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 18, 2026
…he playback context early

根因修复第二处(配合 AudioSource 延迟创建):AudioLoader 解码音频时用的是
AudioManager.getContext()(持久播放 ctx)。真实游戏在启动时(用户手势前)用
resourceManager.load 加载音频,这会提前创建播放 ctx —— iOS 上这种"冷"播放 ctx
小窗来电中断后不自愈(僵尸)。真机实测确认 loader 是 AudioSource 构造之外的第二个
提前创建点。

改用一个 decode-only 的 OfflineAudioContext 解码:它只渲染到内存、不占音频硬件、
在 iOS 中断机制之外,所以提前创建无害;解出的 AudioBuffer 与上下文无关,播放时由
AudioBufferSourceNode 自动重采样到播放 ctx 的率,音调/时长不变。播放 ctx 因此延迟到
首次 play(手势内)热创建,iOS 来电可自愈。

真机验证:真实 resourceManager.load 路径下,load 后播放 ctx 未创建,小窗接电话
挂断自愈、声音正常。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 18, 2026
…he playback context early

根因修复第二处(配合 AudioSource 延迟创建):AudioLoader 解码音频时用的是
AudioManager.getContext()(持久播放 ctx)。真实游戏在启动时(用户手势前)用
resourceManager.load 加载音频,这会提前创建播放 ctx —— iOS 上这种"冷"播放 ctx
小窗来电中断后不自愈(僵尸)。真机实测确认 loader 是 AudioSource 构造之外的第二个
提前创建点。

改用一个 decode-only 的 OfflineAudioContext 解码:它只渲染到内存、不占音频硬件、
在 iOS 中断机制之外,所以提前创建无害;解出的 AudioBuffer 与上下文无关,播放时由
AudioBufferSourceNode 自动重采样到播放 ctx 的率,音调/时长不变。播放 ctx 因此延迟到
首次 play(手势内)热创建,iOS 来电可自愈。

真机验证:真实 resourceManager.load 路径下,load 后播放 ctx 未创建,小窗接电话
挂断自愈、声音正常。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 18, 2026
切后台/锁屏的瞬间,document.hidden 已同步变 true,但 ctx.suspend() 是异步的、
ctx.state 可能还报 running。这个窗口里调 play() 会真的 start() 出一声(真机实测:
切后台后漏一声),随后被冻成 isPlaying=true 但无声的僵尸 source(状态脱节,且依赖
isPlaying 的逻辑会卡住)。

论点是"后台不该启动播放"(用户不在场):此刻既不该出声(漏音),挂起延后又会在
回前台播出对不上的幽灵音。所以 play() 在 document.hidden 时直接 drop:
- 入口加 document.hidden 早返回;
- resume().then() 复查也加 document.hidden(防 resume 期间页面切到后台)。

真机验证:修复后切后台静音、isPlaying=false,不再漏音/留僵尸 source。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 18, 2026
切后台/锁屏的瞬间,document.hidden 已同步变 true,但 ctx.suspend() 是异步的、
ctx.state 可能还报 running。这个窗口里调 play() 会真的 start() 出一声(真机实测:
切后台后漏一声),随后被冻成 isPlaying=true 但无声的僵尸 source(状态脱节,且依赖
isPlaying 的逻辑会卡住)。

论点是"后台不该启动播放"(用户不在场):此刻既不该出声(漏音),挂起延后又会在
回前台播出对不上的幽灵音。所以 play() 在 document.hidden 时直接 drop:
- 入口加 document.hidden 早返回;
- resume().then() 复查也加 document.hidden(防 resume 期间页面切到后台)。

真机验证:修复后切后台静音、isPlaying=false,不再漏音/留僵尸 source。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 20, 2026
iOS Safari 的 back/forward cache(bfcache)恢复页面时,AudioContext 被冻结(interrupted/
suspended),但 bfcache 恢复**只派发 pageshow(persisted=true),不派发 visibilitychange**。
原来只监听 visibilitychange,bfcache 命中时无人恢复 ctx → 循环音/正在播的音频回来后没声。

真机实测确认:导航走再后退、命中 bfcache(persisted=true)时,只监听 visibilitychange
的版本回来没声;补 pageshow 后恢复有声。隔离验证(停用 visibilitychange、仅 pageshow)
命中 bfcache 仍恢复,证明 pageshow 是 bfcache 唯一可靠信号、独立有效。

补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与
回前台相同的 suspend→100ms→resume 恢复(普通加载 persisted=false 不处理)。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 20, 2026
iOS Safari 的 back/forward cache(bfcache)恢复页面时,AudioContext 被冻结(interrupted/
suspended),但 bfcache 恢复**只派发 pageshow(persisted=true),不派发 visibilitychange**。
原来只监听 visibilitychange,bfcache 命中时无人恢复 ctx → 循环音/正在播的音频回来后没声。

真机实测确认:导航走再后退、命中 bfcache(persisted=true)时,只监听 visibilitychange
的版本回来没声;补 pageshow 后恢复有声。隔离验证(停用 visibilitychange、仅 pageshow)
命中 bfcache 仍恢复,证明 pageshow 是 bfcache 唯一可靠信号、独立有效。

补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与
回前台相同的 suspend→100ms→resume 恢复(普通加载 persisted=false 不处理)。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 20, 2026
iOS Safari 的 back/forward cache(bfcache)恢复页面时 AudioContext 被冻结(interrupted/
suspended),但 bfcache 恢复只派发 pageshow(persisted=true)、不派发 visibilitychange。
原来只监听 visibilitychange,bfcache 命中时无人恢复 → 回来没声。真机实测确认(隔离验证
停用 visibilitychange、仅 pageshow 命中仍恢复)。

补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与回
前台相同的恢复。恢复逻辑提取为 _recoverPlaybackContext(原 _onVisibilityChange 改名,
visibilitychange 与 pageshow 复用)。

加 _recovering 重入守卫:bfcache 恢复会同时派发 visibilitychange 和 pageshow,无守卫会
让 suspend→resume 跑两遍(浪费 + 第二个 resume 打在未落地的 suspend 上有竞态)。同时把
resume 链在 suspend().then() 上,不再用裸 timer 排在未 await 的 suspend 之后。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
GuoLei1990 added a commit to GuoLei1990/galacean-engine that referenced this pull request Jun 20, 2026
iOS Safari 的 back/forward cache(bfcache)恢复页面时 AudioContext 被冻结(interrupted/
suspended),但 bfcache 恢复只派发 pageshow(persisted=true)、不派发 visibilitychange。
原来只监听 visibilitychange,bfcache 命中时无人恢复 → 回来没声。真机实测确认(隔离验证
停用 visibilitychange、仅 pageshow 命中仍恢复)。

补 window 'pageshow' 监听,_onPageShow 仅在 event.persisted(真 bfcache 恢复)时走与回
前台相同的恢复。恢复逻辑提取为 _recoverPlaybackContext(原 _onVisibilityChange 改名,
visibilitychange 与 pageshow 复用)。

加 _recovering 重入守卫:bfcache 恢复会同时派发 visibilitychange 和 pageshow,无守卫会
让 suspend→resume 跑两遍(浪费 + 第二个 resume 打在未落地的 suspend 上有竞态)。同时把
resume 链在 suspend().then() 上,不再用裸 timer 排在未 await 的 suspend 之后。

仍 DO NOT MERGE — 临时拆解分支,供与作者 PR galacean#3026 对照与讨论。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants