Skip to content

fix(audio): harden playback lifecycle#3026

Merged
cptbtptpbcptdtptp merged 18 commits into
dev/2.0from
fix/audio-shaderlab-split
Jun 23, 2026
Merged

fix(audio): harden playback lifecycle#3026
cptbtptpbcptdtptp merged 18 commits into
dev/2.0from
fix/audio-shaderlab-split

Conversation

@luzhuang

@luzhuang luzhuang commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the iOS audio lifecycle at its root cause: an AudioContext created before any user gesture turns into a permanently-silent "zombie" after a phone-call interruption on iOS, and no later resume() can revive it. The fix is to defer AudioContext creation until it is actually needed for playback, and to harden context recovery across the page/visibility lifecycle.

Defer AudioContext creation

  • AudioSource no longer creates its gain node in the constructor (which would spin up the AudioContext before any gesture). The gain node is created lazily on first play() via _ensureGainNode(), applying the pre-set _volume at that point. volume/_cloneTo apply lazily too.
  • AudioLoader decodes through a dedicated OfflineAudioContext instead of the playback AudioContext. Decoding runs at load time (before any gesture); an offline context decodes the buffer without forcing the playback context to exist that early.
  • Add an OfflineAudioContext polyfill (iOS 14 and earlier expose only webkitOfflineAudioContext, with callback-form decodeAudioData).

Harden context lifecycle

  • On visibilitychange → hidden, suspend the existing context (desktop/Android don't auto-suspend a backgrounded WebAudio context — only iOS does), but never create a context just to suspend.
  • On returning to foreground (visibilitychange → shown, or a bfcache pageshow with persisted), run the iOS zombie reset suspend() → 100ms → resume(), since an iOS "interrupted" context cannot be resumed directly. A _recovering guard dedupes the bfcache case where both events fire. Ref: https://bugs.webkit.org/show_bug.cgi?id=263627
  • Separate caller-initiated suspend from browser-initiated suspend: a deliberate AudioManager.suspend() (flagged _suspendedByCaller) is not auto-resumed by a later gesture or foreground recovery, until the caller explicitly resume()s.
  • AudioManager.suspend() is a no-op when no context exists — it doesn't create a cold context and doesn't set a ghost caller-suspend flag that would otherwise block later recovery.
  • AudioSource.play() is dropped when document.hidden (don't start — would leak a sound; don't pend — would replay out of sync), and re-checks document.hidden after the async resume() settles.
  • A looping clip resumed from a paused state wraps its start offset into the buffer (startTime % duration) to preserve loop phase, since start()'s offset clamps past the end rather than wrapping.
  • stop() always resets the playback offset to the start, including from an already-paused state.

Verification

  • 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 packages/core/src/audio packages/core/src/Polyfill.ts packages/loader/src/AudioLoader.ts
  • git diff --check
  • See the PR checks for CI status (lint / build on ubuntu·windows·macos / e2e 1–4).

@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.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
🧪 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.

@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 Outdated
Comment thread packages/core/src/audio/AudioManager.ts Outdated
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 84.76821% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.39%. Comparing base (de75496) to head (3387874).
⚠️ Report is 12 commits behind head on dev/2.0.

Files with missing lines Patch % Lines
packages/core/src/audio/AudioManager.ts 78.46% 14 Missing ⚠️
packages/core/src/Polyfill.ts 85.00% 6 Missing ⚠️
packages/core/src/audio/AudioSource.ts 90.90% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3026      +/-   ##
===========================================
+ Coverage    77.48%   79.39%   +1.91%     
===========================================
  Files          914      919       +5     
  Lines       101783   102548     +765     
  Branches     10430    11428     +998     
===========================================
+ Hits         78862    81414    +2552     
+ Misses       22738    20948    -1790     
- Partials       183      186       +3     
Flag Coverage Δ
unittests 79.39% <84.76%> (+1.91%) ⬆️

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.

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 对照与讨论。
…3045)

fix(audio): fix iOS lifecycle at root cause (defer context creation)
@GuoLei1990 GuoLei1990 added Audio bug Something isn't working labels Jun 23, 2026
Comment thread packages/core/src/Polyfill.ts
…lper

_registerOfflineAudioContext 的 callback→Promise decodeAudioData 包装与
_registerAudioContext 一字不差,只 prototype 名不同。抽成共享 helper
_promisifyDecodeAudioData(proto: BaseAudioContext),两处在 webkit* 别名赋值后调用,
避免将来 wrapper 改动时两份漂移。纯重构,行为不变。

@cptbtptpbcptdtptp cptbtptpbcptdtptp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

+1

@cptbtptpbcptdtptp cptbtptpbcptdtptp merged commit 39b4b08 into dev/2.0 Jun 23, 2026
12 checks passed
@GuoLei1990 GuoLei1990 deleted the fix/audio-shaderlab-split branch June 29, 2026 08:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Audio bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants