Skip to content

fix(audio): fix iOS lifecycle at root cause (defer context creation)#3045

Merged
luzhuang merged 9 commits into
galacean:fix/audio-shaderlab-splitfrom
GuoLei1990:fix/audio-root-cause
Jun 23, 2026
Merged

fix(audio): fix iOS lifecycle at root cause (defer context creation)#3045
luzhuang merged 9 commits into
galacean:fix/audio-shaderlab-splitfrom
GuoLei1990:fix/audio-root-cause

Conversation

@GuoLei1990

@GuoLei1990 GuoLei1990 commented Jun 20, 2026

Copy link
Copy Markdown
Member

背景

本 PR 提交到 #3026fix/audio-shaderlab-split),用根因治本的方案替换本分支当前基于 onstatechange / 显式 hide-suspend 的 audio 生命周期修复。

所有问题都在真机 iOS / Android上逐个复现并验证修复——每条修复都对应一个实测能复现的问题,不是推测。

根因(当前分支方案未触及)

iOS audio 各种"中断后起不来"的表现,根子是 AudioContext 在任何用户手势之前就被创建了

  • AudioSource 构造时就 createGain() → 拉起 AudioContext;
  • AudioLoader播放用的 AudioContext 解码 → 加载期就拉起 AudioContext。

这种在手势前创建的"冷"上下文,在 iOS 中断后无法自愈——表现为 state === "running"currentTime 冻结的僵尸态(参考 WebKit 同区域 bug,详见下文)。而在用户手势内创建的"热"上下文,中断后 iOS 会自己恢复——这一点连前台中断(小窗来电横幅 / Siri / 闹钟,页面仍可见、ctx 走 running → interrupted)也成立:中断结束后热 ctx 由 iOS 自行 interrupted → running 复位,无需任何监听器介入。

实测对照:同一份纯净页面,ctx 在手势前创建 → 中断后僵尸;推迟到首次 play()(手势内)创建 → 中断后自愈。

改动

AudioSource — 延迟创建上下文(根因修复)

  • 构造期不再建 gainNode;改为首次 play()_ensureGainNode() 懒建。
  • 因此 AudioContext 推迟到首次 play()(在用户手势回调内)才创建 → "热"上下文 → 中断后 iOS 自愈。
  • play()document.hidden直接丢弃(不启动、也不挂起 pending):后台启动会漏出声音,pending 到回前台又会错位重放——SFX 宁可丢也不能对不上。

AudioLoader — 用 OfflineAudioContext 解码

  • 解码改用专用 OfflineAudioContext(1, 1, 44100),不再用播放 ctx 解码(这是第二个过早创建点)。
  • OfflineAudioContext 只渲染到内存、不碰硬件音频单元,天然在 iOS 中断机制之外,可安全地在加载期(手势前)创建。
  • 无前缀 OfflineAudioContextiOS 14.1+;老 iOS 由 Polyfill 接管:在缺无前缀 API 时为 webkitOfflineAudioContext(自 iOS 7)做别名 + 把其 callback 形式的 decodeAudioData 包成 Promise(对齐既有 _registerAudioContext 的处理)。

AudioManager — 切后台静音、恢复与守卫

  • hide → suspend(全平台对齐)visibilitychange 挂分派器 _onVisibilityChange——hidden_context?.suspend()visible_recoverPlaybackContext
    • 桌面 Chrome/Firefox、Android Chrome 不会自动 suspend 后台运行中的 WebAudio ctx(只有 iOS Safari 会),覆盖版连带删掉 hide→suspend 曾导致这些平台切后台 BGM 继续出声。这里显式补回,让全平台行为对齐 iOS。
    • 用可选链 _context?.suspend()仅在 ctx 已存在时挂起,绝不为了 suspend 去创建一个手势前的冷 ctx(那正是本 PR 要规避的 zombie 来源);也不污染 _suspendedByCaller,故回前台仍正常恢复。
    • 不加 opt-out 开关:iOS Safari 由系统强制后台 suspend、开发者无法绕过,"后台继续放"的开关在 iOS 上必然是个失效的承诺。引擎行业默认也是切后台静音(Unity runInBackground=false、Unreal UnfocusedVolumeMultiplier=0、Phaser pauseOnBlur=true、Cocos visibilitychange 硬挂起),故硬编码默认 hide→suspend。
    • hide→suspend 与延迟创建 ctx(iOS 中断根因)是正交两件事,分开处理。
  • _recoverPlaybackContext:切后台/锁屏回前台且 ctx 非 running 时,做 suspend → 100ms → resume 唤醒僵尸 ctx(iOS 的 interrupted 态不能直接 resume,必须先 suspend 转 suspended)。
  • 恢复定时器 staleness 检查:回前台触发 suspend → 100ms → resume 的窗口内,若页面又被切回后台(document.hidden)或期间发生过显式 suspend()_suspendedByCaller),则不在隐藏页/已主动暂停态上 resume(对齐 play()document.hidden 的 drop 语义)。
  • bfcache:back/forward cache 恢复只派发 pageshowpersisted=true)、不派发 visibilitychange,故 _onPageShow 在 persisted 时同走恢复。(隔离实测:停用 visibilitychange、仅 pageshow 命中仍能恢复循环音。)
  • _recovering 重入守卫:bfcache 恢复会同时派发 visibilitychange 和 pageshow,无守卫会让 suspend→resume 跑两遍(浪费 + 第二个 resume 打在未落地的 suspend 上)。守卫在定时器里无条件清除,不依赖 suspend/resume promise settle(iOS 在 interrupted 态下该 promise 可能永不 settle,会卡死恢复)。
  • _suspendedByCaller:用户显式 suspend() 不被手势/可见性自动唤醒(公开 API 语义:主动暂停不该被自动恢复)。
  • suspend() 无 ctx 时是纯 no-op(公开 API 硬化,两处真机 bug 修复):
    • 不为 suspend 创建 ctx:原先无条件 getContext().suspend(),用户在任何播放前调一次 suspend() 就会凭空创建一个手势前冷 ctx——正是本 PR 要规避的 zombie 来源。加 !_context 守卫,无 ctx 直接 resolve。
    • 不置幽灵标志:无 ctx 时仍 _suspendedByCaller = true 会留下一个"幽灵"标志——首次 play()(手势内建 ctx 并播放)后,它会让回前台的 _recoverPlaybackContext 守卫短路、阻断恢复。这是 Android 专属症状:iOS 新建 ctx 默认 suspended → play() 走 resume 分支(resume 清标志);Android 手势内新建 ctx 立即 running → play()_startPlayback 直播、跳过 resume → 标志残留。修复:suspend() 无 ctx 时不触碰 _suspendedByCaller(状态标志只应在它镜像的动作真实发生过时为真)。

平台归属注释

音频生命周期里多处是平台专属处理(bfcache pageshow、手势 resume、_resumeAfterInterruption 都只为 iOS Safari)。注释明确标注 "iOS Safari",避免后人误当通用逻辑删掉。

移除 onstatechange

实测确认 onstatechange 驱动的恢复无可挽回的场景,且它本身正是冷 ctx 僵尸态的来源之一:

  • 独占中断(来电)由根因(延迟创建 → 热 ctx)覆盖,iOS 自愈;
  • 前台中断(小窗来电横幅 / Siri,页面仍可见)实测同样由热 ctx 自愈(iOS 自行 interrupted → running),无需监听器;
  • 来电之外的纯外部中断在 iOS 上没有可触发场景(放音乐/视频是叠加混播、不打断)。

故删除不漏任何可复现场景。

测试

重写为 AudioSourcePendingPlayback.test.ts(mock AudioContext,无需真引擎,browser-mode vitest 全绿)覆盖:延迟创建 / 懒 gainNode / pre-play volume 首播生效;play 守卫(无 clip / 已播 / 已 pending no-op,running 立即播);hidden 丢弃(不 pend、不 suspend,丢弃后回前台不重放);pending 重放与取消、autoplay-blocked 丢弃;resume 解锁与重叠合并;hide→suspend 与 staleness(隐藏页/主动暂停不 resume);显式 suspend 不自动唤醒、suspend 无 ctx 不创建/不置标志;僵尸态 suspend → 100ms → resume、suspend reject 不阻断 scheduled resume;bfcache pageshow(persisted)恢复与 _recovering 重入守卫;记账(_playingCount / 偏移)。

真机验证矩阵:

验证项 平台 结果
切后台 hide→suspend 静音 Android Chrome ctx running → suspended,后台静音生效
回前台恢复出声(未主动 suspend) iOS + Android 回前台恢复出声
幽灵标志修复(先 suspend() 无 ctx → play → 切后台 → 回前台) Android Chrome 修复后回前台恢复出声(修复前无声)
前台中断自愈(小窗来电横幅 / Siri / 闹钟) iOS Safari 页面可见时 ctx running → interrupted,中断结束 iOS 自行 interrupted → running,无需 onstatechange

另:来电独占中断自愈、bfcache 恢复(隔离验证 pageshow 独立有效)、后台 play 漏音丢弃、_recovering 连续切后台多次每次恢复,均已真机验证(见下方复现/修复对比页面)。

兼容性

  • 功能可用下限 iOS ≥ 9AudioContext.suspend()/resume() 返回 Promise 自 iOS 9 起;OfflineAudioContext 自 iOS 14.1+,老 iOS 经 Polyfill 接到 webkitOfflineAudioContext(自 iOS 7)。
  • document.hidden / visibilitychange / pageshow 均为已广泛支持的标准 API。

真机复现 / 修复对比(iOS Safari)

每个问题都提供「复现版」与「修复版」两个可直接在 iOS Safari 打开的页面(均打包真实引擎):

问题 复现版(修复前) 修复版(修复后) 操作 & 判定
切后台/锁屏回前台不出声 q1-norescue q1-minfix 播放循环音 → 切后台/锁屏数秒 → 回前台。复现版无声,修复版恢复
显式 suspend 被手势误唤醒 q2-repro q2-fixed 播放 → 主动 suspend → 点屏幕。复现版被误唤醒,修复版保持暂停
来电中断后僵尸态(根因) q3-repro q3-loaderfix2 播放 → 接打一通电话 → 挂断。复现版 state=running 但无声(僵尸),修复版 iOS 自愈出声
后台 play 漏音 b-gate-test b-gate-fixed 切后台时触发 play。复现版后台漏出一声,修复版静默丢弃
bfcache 恢复不出声 bfcache-test bfcache-fixed 播放 → 导航到别的页 → 后退(bfcache 命中 persisted=true)。复现版无声,修复版恢复

页面顶部实时显示 AudioContext 的 state / currentTime(是否在走)等内部状态,便于对照「复现 vs 修复」的差异。

后续修复的真机验证(单页 + 操作,均用本 PR 最终引擎)

以下三处问题(hide→suspend、幽灵标志、前台中断自愈)的验证页用最终引擎打包,靠操作 + 页面实时状态判定(不是修复前/后两版对照):

验证项 页面 操作 & 判定
切后台静音(hide→suspend,桌面/Android WebAudio 后台不自停) bg-suspend-probe(纯 WebAudio 探针,证明平台不自动 suspend)/ fg-interrupt-test(真引擎) 桌面 Chrome / Android:播放 → 切 tab/最小化。纯 WebAudio 探针证明平台不自停(一直响);真引擎修复后 ctx running → suspended、后台静音
幽灵标志(先 suspend() 无 ctx → play → 切后台 → 回前台) fix-a-verify Android:点按钮(先 suspend 无 ctx → 立即 play)→ 看 _suspendedByCaller 应为 false → 切后台 → 回前台恢复出声(修复前 Android 残留 true、不恢复)
前台中断自愈(小窗来电横幅 / Siri,页面仍可见) fg-interrupt-test iOS:前台播放 → 制造小窗来电/Siri → ctx running → interrupted(页面仍 visible)→ 挂断后什么都不点,ctx 自行 interrupted → running、BGM 自响(热 ctx 自愈,无需 onstatechange)

与原方案 #3026 的逐项对比

作者 #3026 在出口处打补丁:用 onstatechange + 动态挂/摘手势监听 + _hidden 镜像 + _foregroundResumeTimer 一整套状态机去追逐已损坏的 AudioContext 状态,但没碰根因——AudioSource 构造函数和 AudioLoader 解码都在任何用户手势之前就创建了播放 ctx,iOS 上这种 pre-gesture 冷 ctx 中断后会永久停在静音 zombie 态,再多恢复逻辑也救不回。本 PR 从输入端修根因,并覆盖作者全部场景:

关切点 作者 #3026 本 PR 判定
上下文创建时机(根因) AudioSource 构造即 getContext().createGain() → ctx 在手势前冷创建,iOS 中断后永久 zombie gainNode 首次 play()_ensureGainNode() 懒建,构造不碰 ctx → ctx 必在手势内热创建 更优(修了作者没碰的根因)
解码上下文 AudioLoader 复用播放 ctx 解码 → 加载期就把播放 ctx 拉起,同样踩 zombie 独立 OfflineAudioContext(1,1,44100) 解码,完全不触播放 ctx 更优
独占中断(来电)后恢复 恢复动作机制相近,但作用在冷 ctx 上——iOS 来电后冷 ctx 已 zombie,恢复失效 根因修复后 ctx 是热的,iOS 来电后自愈;手势 resume 仅作兜底 更优(同样的恢复动作,热 ctx 才真生效)
切后台/锁屏回前台恢复 _onShownsuspend → 100ms → resume_foregroundResumeTimer 守 staleness _recoverPlaybackContext → 同样 suspend → 100ms → resume_recovering 守重入 + 隐藏页/主动暂停 staleness 检查 同等
切后台静音(hide→suspend) hide 时 suspend _onVisibilityChange hidden 分支 _context?.suspend()(仅在 ctx 已存在时,不创建、不污染 _suspendedByCaller);Android 真机验证后台静音 同等(覆盖版误删后已补回,并避免为 suspend 创建冷 ctx)
bfcache 恢复 _onShown 第一行 if (!_hidden) return;bfcache 只发 pageshow 不发 visibilitychange,从未置 _hidden恢复不了 _onPageShow 显式判 event.persisted 走恢复,_recovering 防双触发 更优(作者此路不通)
后台 play(hidden) _canStartPlayback 在 hidden 时先 _onHidden 再 return → 走 pending,回前台异步补播 play() 首行 if (document.hidden) return 直接丢弃(不漏音、不乱序补播) 更优
主动 suspend 不被误唤醒 _suspendedByCaller,散布 5 处判 _suspendedByCaller,集中收口;且 suspend() 无 ctx 时纯 no-op、不置幽灵标志(修 Android 回前台不恢复的真机 bug) 更优(标志只在动作真实发生时置位)
外部中断(onstatechange) context.onstatechange 检测翻转挂手势监听 移除——独占中断由根因覆盖;前台中断(小窗来电/Siri)实测由热 ctx 自行 interrupted → running 自愈,iOS 真机验证;来电之外的纯外部中断在 iOS 无可触发场景 有意移除(无功能回退,前台中断已经真机验证由热 ctx 兜住)
可见性状态 _hidden 布尔镜像,多处读写易与 document.hidden 漂移 无镜像,直接读 document.hidden(单一事实源) 更优

移除的作者机制(已逐一核对作者代码确实存在,本 PR 全部移除且无功能回退):_onContextStateChange(onstatechange) / _canStartPlayback / _hidden / _foregroundResumeTimer / _addGestureListeners / _removeGestureListeners / _onHidden / _onShown。根因修复后这些追逐损坏状态的机制不再需要,常驻可见性监听 + 直接读 document.hidden 即等价覆盖。

净效果:覆盖作者全部场景(无一遗漏),修了他没触及的 iOS zombie 根因,删掉一整套易漂移的可见性状态机;同时补回全平台 hide→suspend 静音、硬化公开 suspend()、修复 Android 幽灵标志 bug。净 diff +539 / -391(含重写的测试)。

关于移除 onstatechange 的取舍:onstatechange 在 #3026 中用于追踪外部中断,但实测它能覆盖的场景已被根因(热 ctx)全部接管——独占来电中断、前台中断(小窗来电横幅/Siri,已 iOS 真机验证)均由 iOS 对热 ctx 自行 interrupted → running 复位,而"不触发 visibility/pageshow 的纯外部中断"在 iOS 上无法触发(放音乐/视频是叠加混播)。故移除无实际功能损失。

把 audio 播放生命周期修复改为根因治本,替换本分支原先基于 onstatechange/
显式 hide-suspend 的方案。所有问题均在真机 iOS Safari 实测复现并验证修复。

根因(本分支原方案未触及):AudioContext 在任何用户手势之前就被创建——
AudioSource 构造时建 gainNode、AudioLoader 用播放 ctx 解码——这种"冷"上下文
在 iOS 来电中断后无法自愈(state 报 running 但 currentTime 冻结的僵尸态)。

改动:
- AudioSource: 不在构造期建 gainNode,改为首次 play 时 _ensureGainNode 懒建;
  ctx 因此推迟到首次 play(用户手势内)才创建,变"热"上下文,来电后 iOS 自愈
- AudioLoader: 用专用 OfflineAudioContext(1,1,44100)解码,不再用播放 ctx
  解码(这是第二个过早创建点);要求 iOS >= 14.5(无前缀 OfflineAudioContext)
- AudioManager: 切后台/锁屏回前台时 _recoverPlaybackContext 做 suspend→100ms
  →resume 唤醒僵尸 ctx(对齐 webkit #263627);bfcache 恢复只派发 pageshow
  (persisted)不派发 visibilitychange,故 _onPageShow 同走恢复;_recovering
  重入守卫防 bfcache 双事件(visibilitychange+pageshow)跑两遍;_suspendedByCaller
  让显式 suspend() 不被手势/可见性自动唤醒;play() 在 document.hidden 时直接丢弃
  (不启动也不挂起,避免回前台错位重放或后台漏音)

移除原方案的 onstatechange:实测"来电之外的外部中断"在 iOS 上无可触发场景
(放音乐/视频是叠加混播、Siri 不打断),唯一独占中断是来电、已由根因覆盖;
而 onstatechange 驱动的恢复正是冷 ctx 僵尸的来源之一。

测试:重写为 AudioSourcePendingPlayback.test.ts,覆盖延迟创建、懒 gainNode、
hidden 丢弃、pending 重放/取消、autoplay-blocked 丢弃、resume 合并、显式
suspend 不自动恢复、僵尸态恢复(suspend→100ms→resume)、_recovering 重入守卫、
bfcache pageshow(persisted)恢复、suspend reject 不阻断 resume 等 23 个用例。
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • ^(dev/)?\d+.\d+$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c238e988-2e26-4711-b898-d6a471a7e9f9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

GuoLei1990

This comment was marked as outdated.

回应 review:覆盖版连带删了全部 hide→suspend,导致桌面 Chrome/Firefox、Android
Chrome 切 tab/最小化时音频继续在后台出声——这些平台不会自动 suspend 正在运行的
WebAudio ctx(只有 iOS Safari 会)。纯 WebAudio 真机实测确认后台一直响。

“切后台静音”是引擎行业默认(Unity runInBackground=false、Unreal
UnfocusedVolumeMultiplier=0.0、Phaser pauseOnBlur=true、Cocos visibilitychange 硬挂起)。
不加 opt-out 开关:Web 上 iOS Safari 系统强制后台 suspend、开发者无法绕过,给“后台
继续放”的开关在 iOS 上必然失效,故硬编码默认 hide→suspend、全平台对齐 iOS 约束。

- visibilitychange 改挂分派器 _onVisibilityChange:hidden → _context?.suspend()
  (仅在 ctx 已存在时,不为 suspend 去创建 ctx,不污染 _suspendedByCaller,故回前台
  仍正常恢复);visible → _recoverPlaybackContext
- 恢复定时器 resume 前加 document.hidden / _suspendedByCaller staleness 检查:回前台
  触发 suspend→100ms→resume 的窗口内若又切回后台,不再在隐藏页 resume(对齐 play()
  对 document.hidden 的 drop)

hide→suspend 与延迟创建 ctx(iOS 来电根因)是正交两件事,分开处理。补 4 个测试。
@GuoLei1990

Copy link
Copy Markdown
Member Author

感谢 review,[P1] 确认成立,已修复(commit a8eda78)。两个子问题都处理了:

1. hide→suspend(主问题) — 确实是退化。纯 WebAudio 真机实测确认:桌面 Chrome/Firefox、Android Chrome 切 tab/最小化时不会自动 suspend 正在运行的 AudioContext(只有 iOS Safari 会),所以删掉 hide→suspend 后这些平台后台会继续出声。

修复:visibilitychange 改挂分派器 _onVisibilityChange —— document.hidden_context?.suspend()仅在 ctx 已存在时,不为了 suspend 去创建 ctx,以免破坏延迟创建的根因修复;用底层 context.suspend() 而非 AudioManager.suspend()不污染 _suspendedByCaller,故回前台仍正常恢复);可见时走 _recoverPlaybackContext

2. 恢复定时器 staleness — 也修了。_recoverPlaybackContext 的 100ms 定时器在 resume() 前加了 if (document.hidden || _suspendedByCaller) return,回前台触发 suspend→100ms→resume 窗口内若又切回后台,不再在隐藏页 resume —— 与 play()document.hidden 的 drop 对称。

关于是否做成可配置:考虑过加 pauseOnHide 开关(Unity runInBackground / Unreal UnfocusedVolumeMultiplier / Phaser pauseOnBlur 都给了 opt-out),但最终硬编码默认 hide→suspend、不加开关,因为 iOS Safari 系统强制后台 suspend、开发者无法绕过 —— 给一个"后台继续播"的开关在 iOS 上必然失效,反而是兑现不了的承诺。硬编码全平台对齐 iOS 的硬约束更一致(语义上对标 Cocos 的 visibilitychange 硬挂起)。

正交性:hide→suspend(切后台静音)和延迟创建 ctx(修 iOS 来电根因)是两件正交的事,已分成独立 commit,没有再耦合。

AudioManager.suspend() 是公开 API,原先无条件 getContext().suspend()——用户在任何
播放前调一次就会凭空创建一个手势前的“冷”上下文,正是本 PR 根因要规避的 iOS zombie
来源。加 !_context 守卫:无上下文时直接 resolve(没东西在播,无需 suspend)。
@GuoLei1990

Copy link
Copy Markdown
Member Author

两个 [P2] 也处理了:

[P2] suspend() 创建冷上下文 — 确认是真问题,已修(commit fd15f7c)。这恰恰打脸本 PR 的根因(手势前冷 ctx 正是要规避的 zombie 来源),而 suspend() 是公开 API、引擎内部无调用方。加回守卫:

static suspend(): Promise<void> {
  AudioManager._suspendedByCaller = true;
  const context = AudioManager._context;
  return context ? context.suspend() : Promise.resolve();
}

无 context 时直接 resolve(没东西在播,无需 suspend),不创建。补了测试。

[P2] _initSourceNode/_clearSourceNode 硬化回退 — 确认是有意回退,保持 mainline 形态。逐条核了这 4 处守卫针对的场景在当前引擎是否可达:

  • offset >= duration → return false / offset %= durationoffset 只来自 pause 记录的已播时长,必然 < duration;引擎当前无 time/seek setter,用户无法把起点设到越界。→ 不可达 / 取模是 no-op。
  • _clearSourceNodeif (!sourceNode) returntry/catch_clearSourceNode 只在 _isPlaying===true 分支被调,而 _isPlaying===true ⟺ _sourceNode!==null,不会 NPE、不会 double-stop。→ 不可达。

这 4 处都是针对当前不可达场景的防御性校验,加回会为假想场景增加复杂度(且 offset 逻辑需把 _initSourceNode 改成返回 boolean + 调用方分支)。本 PR 倾向 runtime 不做防御性校验,故保持 mainline 形态。等引入 time/seek setter(能让 offset 越界)时再加才是恰当时机——届时这些守卫有了真实可达的触发路径。若 #3026 当时是真机踩到才加的(commit 442552c51 message 为空、未说明),烦请告知具体复现,我再补。

音频生命周期里多处是平台专属处理(bfcache pageshow、手势 resume、手势兜底都只为
iOS Safari),原注释没点明平台,容易被误当通用逻辑误删。补上平台归属:bfcache /
pageshow / _resumeAfterInterruption 三处注释明确标注 iOS Safari。

仅注释。
GuoLei1990

This comment was marked as outdated.

GuoLei1990

This comment was marked as outdated.

suspend() 在无 context 时本应是 no-op(没有正在播放的音频可暂停),却仍置了
_suspendedByCaller=true,留下一个“幽灵”标志。之后首次 play()(在用户手势内创建
context、开始播放)后,这个标志会让切后台再回前台的 _recoverPlaybackContext 守卫
短路,导致音频无法恢复。

真机复现:序列「先 suspend()(无 ctx)→ play → 切后台 → 回前台」在 Android Chrome
不恢复(无声),iOS 正常。根因平台分歧:iOS 新建 context 默认 suspended → play 走
resume 分支(resume 清标志);Android 在手势内新建 context 立即 running → play 走
_startPlayback 直接播、跳过 resume → 标志残留 true。

修复落在源头:无 context 时直接 resolve 且不置标志(suspend 一个不存在的播放不该
留状态)。补回归测试覆盖该序列。Android 真机验证回前台恢复出声。
GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 added bug Something isn't working Audio labels Jun 22, 2026
@luzhuang

Copy link
Copy Markdown
Contributor

问题

  • [Bugs] [P1] stop() 不再重置已暂停音源
    packages/core/src/audio/AudioSource.ts:194_pausedTime/_playTime 的重置放进了 if (this._isPlaying)。路径是:play() -> pause() -> stop()pause()_isPlaying=false,但 _pausedTime/_playTime 仍保留暂停状态;此时 stop() 只清 _pendingPlay,不会清暂停 offset。之后再 play() 会从旧暂停点继续播,而不是从 0 开始。base 版本是无条件重置这两个字段。建议把 _pausedTime = -1 / _playTime = -1 挪回 _isPlaying 分支外;只有 _clearSourceNode()_isPlaying = false_playingCount-- 需要留在分支里。

  • [Bugs] [P1] 循环音频恢复时用了累计播放时长,而不是循环相位
    packages/core/src/audio/AudioSource.ts:269 用总 elapsed time 算 startTime:289 直接传给 sourceNode.start(0, startTime)。base 里对 loop clip 有 offset %= duration。比如 10 秒 BGM 循环播放 35 秒后暂停,正确恢复点应是 5 秒;当前 PR 会传 35 秒。按 MDN 对 AudioBufferSourceNode.start() 的说明,超过可播放范围的 offset 会被 clamp,不会自动取模,所以会丢失循环相位。建议恢复 loop-only modulo,并补一个 loop=truecurrentTime > duration 的测试。

其余当前 head 暂未发现新的 blocker。已有 review 里的 hidden suspend 和 no-context suspend() 问题,在 b52f998be 已闭环。

验证:已核对当前 head b52f998beAudioSource.ts / AudioManager.ts,并与 base 54ef72e8dAudioSource.ts 对比。测试命令 pnpm exec vitest run tests/src/core/audio/AudioSourcePendingPlayback.test.ts 因本地缺 Playwright Chromium 未能完成。

@luzhuang

Copy link
Copy Markdown
Contributor

问题

  • [Bugs] [P1] recovery delay 期间已经打开手势 fallback,可能绕过 100ms suspend→resume 延迟
    AudioManager._recoverPlaybackContext() 里先设置 _needsUserGestureResume = true,再 context.suspend(),最后 100ms 后 context.resume()。但 getContext() 创建 context 时已经永久注册了 touchstart/touchend/click 监听,所以前台恢复后的 100ms 窗口内,只要用户点一下页面,_resumeAfterInterruption() 就会立刻调用 AudioManager.resume(),绕过注释里强调的 “100ms empirical delay”。建议要么在 _resumeAfterInterruption()if (_recovering) return,要么把 _needsUserGestureResume = true 延迟到 100ms timer 内设置,避免 fallback 抢跑 auto-recovery。

  • [Bugs] [P1/P2] 删除 AudioContext.onstatechange 后,前台非 running 状态可能没有恢复入口
    base 版本在 getContext() 里设置了 context.onstatechange,并在前台、非 caller suspend、context 非 running 时打开 user gesture fallback。当前 head 只依赖 visibilitychange / pageshow.persisted。如果 AudioContext 在页面仍 visible 时进入非 running 状态,但没有触发 visibility/page lifecycle 事件,_needsUserGestureResume 不会被置 true,下一次用户手势也不会触发 resume()。建议恢复一个轻量 onstatechange,并用 _playingCount > 0 作为 gate,避免无音频时误恢复。

  • [Bugs] [P2] resume() 在 hidden 页面仍会创建/恢复 AudioContext
    当前 resume() 不检查 document.hidden,会直接 getContext().resume()。虽然 AudioSource.play() 已经有 hidden guard,但 AudioManager.resume() 是 public static,外部仍可直接调用。hidden 时 resume 可能让被 visibilitychange suspend 的音频在后台恢复,也和“隐藏页只 suspend、不恢复”的生命周期约束不一致。base 版本 hidden 时会直接 resolve,不恢复 context。建议在 resume() 里恢复 hidden guard:hidden 时只 suspend existing context 并返回 resolved promise,不创建/恢复 context。

非 blocker 建议

isAudioContextRunning() 当前会通过 getContext() 创建 AudioContext。这个函数名更像纯查询,建议改成 return AudioManager._context?.state === "running";,避免后续调用方只是查询状态却意外创建 cold context。

@luzhuang

Copy link
Copy Markdown
Contributor

问题

  • [Bugs] [P1] stop() 不再重置 paused 状态,导致 stop 后再次 play 从旧暂停点恢复
    当前 stop()_pausedTime/_playTime 的 reset 放在 if (this._isPlaying) 分支内。路径是 play() -> pause() -> stop()pause()_isPlaying=false,但 _pausedTime/_playTime 仍保留暂停位置;此时 stop() 只清 _pendingPlay,不会清暂停 offset。之后再 play() 会从旧暂停点继续,而不是从 0 开始。base 版本是在 _isPlaying 分支外无条件 reset 这两个字段。建议把 _pausedTime = -1 / _playTime = -1 挪回分支外;只有 _clearSourceNode()_isPlaying=falseAudioManager._playingCount-- 需要留在分支里。

  • [Bugs] [P1] loop 恢复播放丢了 modulo,非 loop 超 duration 的保护也被删掉了
    当前 _startPlayback() 用累计 elapsed time 算 startTime_initSourceNode() 直接 sourceNode.start(0, startTime)。base 里会对 loop clip 做 offset %= duration,对 non-loop 且 offset >= duration 的情况直接返回 false。比如 10 秒 BGM 循环播放 35 秒后暂停,正确恢复点应是 5 秒;当前会传 35 秒。AudioBufferSourceNode.start() 的 offset 超过可播放范围会被 clamp,不会自动取模,所以会丢失循环相位。建议恢复 base 的 offset normalize 逻辑,并让 _initSourceNode() 返回 boolean,启动失败时不要设置 _isPlaying / _playingCount++

  • [Bugs] [P2] pending resume 成功后没有重新校验 _clip._getAudioSource()
    play() 入口检查了 _clip?._getAudioSource(),但 AudioManager.resume().then(...) 成功后只检查 !this._clip。如果 pending resume 期间 clip 的 audio buffer 被销毁或清空,但 clip 对象还在,当前仍会 _startPlayback()。base 版本会重新检查 !this._clip?._getAudioSource()。建议在 resume success callback 里也使用 !this._clip?._getAudioSource()

  • [Bugs] [P2] _clearSourceNode() 丢了 defensive cleanup
    当前 _clearSourceNode() 直接 stop() / disconnect(),且在 stop() 后才清 onended。base 版本有 null guard、先清 onended、并用 try/catch 包住 stop()。建议恢复这个 defensive cleanup,避免 source 已结束或浏览器 stop 行为差异导致 cleanup 中断,进而污染 _isPlaying / _playingCount 状态。

回应 review 两条 [P1]:

1. stop() 不再重置已暂停音源的播放偏移。`_pausedTime`/`_playTime` 的重置原先放在
   `if (this._isPlaying)` 分支内,而 `play() → pause() → stop()` 后 `_isPlaying`
   已为 false,导致重置被跳过;再 `play()` 会从旧暂停点续播而非从 0 开始,违反
   stop() 的归零契约。把重置移出分支:stop() 无条件归零(拆 source node 仍只在
   有 node 时做)。

2. 循环音频恢复用累计播放时长当 offset,丢失循环相位。`_startPlayback` 的
   `startTime` 是累计已播时长,循环播放会无限增长越过 buffer 时长;直接传给
   `sourceNode.start(0, offset)` 时——按 spec,越界 offset 被 clamp(不 wrap)——
   会丢相位(10s 循环播 35s 应恢复到 5s,却传 35s)。loop 时对 buffer.duration
   取模,把累计时长折回 buffer 内的循环相位。non-loop 不取模(暂停点天然 < 时长)。

补回归测试:play→pause→stop→play 从 0 开始;loop 累计 35s 恢复 start(0, 5)。
@GuoLei1990

Copy link
Copy Markdown
Member Author

两条 [P1] 都确认是真 bug、已修复(commit 8f96b26),感谢揪出——这两处是覆盖时把 base 的 stop()/_initSourceNode 回退成了更早的简化版,丢了正确逻辑。逐条核过可达性后修复:

1. stop() 不重置已暂停音源 — 真机实锤复现:play → pause(暂停在 7.18s) → stop → play,stop 后 _pausedTime 残留 7.18(未归零),再 play 从 7.18s 续播。play/pause/stop/play 全是正常 API 组合,违反 stop 归零契约。修复:把 _pausedTime = -1 / _playTime = -1 移出 if (this._isPlaying) 分支,无条件归零(拆 source node 仍只在有 node 时做)。

2. 循环音频恢复丢失循环相位 — 核实 MDNstart() 的 offset 越界是 clamped(不 wrap)。_startPlaybackstartTime 是累计已播时长,循环播放会无限增长越过 buffer 时长(10s 循环播 35s → startTime=35),直接传给 start(0, 35) 会被 clamp 到末尾、丢相位。修复:loop 时对 buffer.duration 取模(35 → 5);non-loop 不取模(暂停点天然 < 时长,不会越界)。

补了回归测试:play→pause→stop→play 第二次从 0 开始;loop=true 累计 35s 恢复时 start(0, 5)

Copy link
Copy Markdown
Contributor

复核了当前 HEAD 8f96b262

两条 [P1] 已闭环:

  • stop() 不重置 paused offset:已修。_pausedTime/_playTime 已回到 _isPlaying 分支外无条件 reset,并补了 play -> pause -> stop -> play 从 0 开始的回归测试。
  • loop resume 丢 modulo:已修。loop clip 现在按 startTime % buffer.duration 恢复循环相位,并补了 35s / 10s -> offset 5 的测试。

关于上一条里提到的 non-loop offset >= duration -> return false:这次没有恢复 _initSourceNode(): boolean,但当前 public path 下非 loop 超 duration 没有清晰可达路径,和之前判断一致,我不再把它作为 blocker;最多算防御性 P2。

剩余非 blocker:

  • AudioManager.resume() hidden guard 仍未恢复;public static 直接调用 hidden resume 仍可能创建/恢复 ctx,建议后续收敛,但不挡这轮主修复。
  • _recoverPlaybackContext() 在 100ms delay 前打开 gesture fallback 仍成立;如果想完全遵守 100ms empirical delay,最小修法是在 _resumeAfterInterruption()_recovering 时 return。
  • onstatechange fallback 仍未恢复。PR 描述和真机验证给出的取舍是:热 ctx 下 iOS 前台中断可自愈;这个目标下可以接受。剩余风险是 visible 状态下 ctx 非 running 且无 visibility/pageshow 事件的路径没有测试兜底。

结论:当前 head 没有我认为需要阻塞合并的 P1。

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

总结

复审新 HEAD 8f96b2627。自上轮 b52f998be 以来唯一功能改动就是 8f96b2627——回应 luzhuang 的两条 [P1]:stop() 从暂停态归零失败、loop 恢复丢循环相位。两处都是当初覆盖 base 时把 54ef72e8dstop()/_initSourceNode 回退成更早简化版丢的逻辑,现已修复,回归测试是有效反向见证。本轮无新问题

已关闭问题清单(全部闭环)

  • [P1] hide→suspend 删光a8eda78a1_onVisibilityChange 分派器)。
  • [P1-子] 恢复定时器 resume 前不重查 hiddena8eda78a1(line 120 staleness 守卫)。
  • [P2] suspend() 为 suspend 建冷 ctxfd15f7c8c + b52f998be 收紧(无 ctx 不置幽灵标志)。
  • [P2] _initSourceNode/_clearSourceNode 硬化回退(含 non-loop offset>=duration→return false — 当前无 seek/time setter,offset>=duration 不可达、_isPlaying ⟺ _sourceNode 故 NPE 不可达,与 mainline 一致。luzhuang 本轮也独立判为"没有清晰可达路径,最多防御性 P2",与我结论一致,关闭。
  • [P1] stop() 不重置已暂停音源(luzhuang) — 已修 8f96b2627(见下核对)。
  • [P1] loop 恢复丢 modulo(luzhuang) — 已修 8f96b2627(见下核对)。

增量改动核对(8f96b2627

两处修复落点都在输入端、归零契约层,非输出端打补丁,正确。逐条 trace:

1. stop() 无条件归零(line 197-205)_pausedTime/_playTime = -1 移出 if (this._isPlaying),拆 source node 仍留在分支内(无 node 不拆)。路径 play→pause→stop→playpause()_isPlaying=false_pausedTime 残留暂停点;旧版 stop() 跳过归零 → 再 play()startTime = _pausedTime - _playTime 续播旧点。修后 stop() 无条件 _pausedTime=-1_startPlayback 算出 startTime=0 从头播。符合 stop() 的归零语义(vs pause() 保留偏移)。

2. loop 恢复对 buffer 取模(line 293)startTime 是累计已播时长(_pausedTime - _playTime),循环播放会无限增长越过 buffer.durationAudioBufferSourceNode.start(when, offset) 的越界 offset 按 spec 是 clamp 到末尾、不 wrap,所以直接传累计值会丢相位(10s 循环播 35s 应恢复到 5s,旧版传 35→clamp 到 10)。修后 this._loop && buffer.duration > 0 ? startTime % buffer.duration : startTime:loop 折回循环相位(35%10=5),non-loop 不取模(暂停点天然 <时长)。buffer.duration > 0 守住除零。边界 startTime 恰为时长整数倍时 % 得 0 = 从循环起点播,正确。

3. 回归测试是有效反向见证 — 两测试我都心算验过 revert 即 fail:

  • stop() resets the paused offsetplay(t=5)→pause(t=8,_pausedTime=8)→stop→play(t=12),断言 time===0。若把归零移回 if(_isPlaying) 分支,stop() 跳过归零 → 二次 playstartTime=8-5=3time=3≠0,FAIL。✓
  • wraps the resume offset into the loop:mock clip duration:10,构造 _pausedTime=35,_playTime=0,loop=true,断言 start 调用参数 (0, 5)。去掉 %durationstart(0,35),FAIL。✓

注释合规:line 203 单行 // 无尾句号、解释 why(stop 归零含暂停态);line 291-292 双行 // 无尾句号、解释 clamp-not-wrap 的 spec 依据。均合规。

问题

无新问题。

luzhuang 在 06:13:35Z/06:17:18Z 列的三条非 blockerresume() 缺 hidden guard、_recoverPlaybackContext 100ms 窗口内手势 fallback 抢跑、删除 onstatechange fallback)我独立核过,确认与其判断一致,均不阻塞本轮主修复,已在记录上,不重复展开:

  • resume() hidden guard:仅 public static 直调时可在隐藏页恢复 ctx;引擎自身 play() 已有 document.hidden drop。属边界收敛,可后续处理。
  • 手势 fallback 抢跑:100ms 窗口内点击会经 _resumeAfterInterruption(不查 _recovering)直接 resume(),绕过 suspend→100ms→resume;但 _resumePromise 去重 + 失败后 _needsUserGestureResume 留 true 可重试,自愈。最小修法(_resumeAfterInterruptionif (_recovering) return)合理,留作后续。
  • onstatechange 删除:是本 PR"延迟创建 = 热 ctx 自愈"路线相对 #3026 的有意取舍,我首轮即认同优于 onstatechange。剩余风险(visible 下 ctx 非 running 且无 visibility/pageshow 事件)无测试兜底,可补但不阻塞。

简化建议

无。stop() 的"无条件归零 + 仅有 node 时拆 source"与 loop 的 loop && duration>0 ? %duration : raw 三元已是最简正确形态。

Copy link
Copy Markdown
Contributor

再从实现形态看了一遍:大方向我认可,延迟创建播放 AudioContext + OfflineAudioContext 解码 + visibility/pageshow 恢复,比 #3026 那套追状态机更干净。

我觉得当前还有两处值得顺手收掉的小绕路,都是最小改动:

  1. AudioManager.resume() 仍缺 hidden guard
    这是 public static,但当前 hidden 页直接 getContext().resume(),会和本 PR “hidden 只 suspend、不恢复、不创建冷 ctx” 的主线不一致。建议 hidden 时只 suspend existing context 并 resolve,不创建/恢复:

    if (document.hidden) {
      AudioManager._context?.suspend().catch(() => {});
      return Promise.resolve();
    }
  2. _recoverPlaybackContext() 在 100ms auto-resume 前已经打开 gesture fallback
    当前先 _needsUserGestureResume = true,用户 100ms 内点击会绕过注释里强调的 empirical delay。最小收敛是在 _resumeAfterInterruption() 里恢复期间直接 return:

    if (AudioManager._recovering) return;

isAudioContextRunning() 作为查询函数会创建 ctx,语义也不够干净;但改成 _context?.state === "running" 会轻微改首次 play() 路径,我不建议这轮扩大。

_playingCount / _recovering 我不认为是过度设计,它们分别挡“无播放也恢复”和 bfcache 双触发,有真实平台约束支撑。_clearSourceNode() 直接版也可以接受,除非有浏览器实证 stop() 在 ended 后会 throw,否则不需要为了 defensive cleanup 把 base 那段全搬回来。

回应 review:_recoverPlaybackContext 的 100ms 定时器原先裸调 context.resume(),不走
AudioManager.resume()=不占用 _resumePromise;恢复窗口内手势穿过守卫后再走
AudioManager.resume() 时,promise 仍是 null,于是又一次 context.resume()。真机 iOS
恢复过程中狂点屏幕可复现 2 次 context.resume()(节奏取决于 iOS interrupted→running
落地耗时,经验 300~400ms)。

修复:
- 定时器改走 AudioManager.resume(),让 _resumePromise 在恢复期占住合并锁;后续手势
  再调 AudioManager.resume() 时 ??= 短路、复用同一 promise,整窗口只一次
  context.resume()。
- _resumeAfterInterruption 加 _recovering 守卫:suspend→100ms→resume 的前 100ms
  窗口里手势直接 return,不在 suspend 未落地时抢跑(守注释强调的 empirical delay)。

补回归测试:
- 恢复中(_recovering=true)手势不触发 resume
- 恢复后(_recovering=false)resume 未 settle 的 click 风暴被 _resumePromise 合并为
  单次 context.resume()
@GuoLei1990

Copy link
Copy Markdown
Member Author

建议 2(_resumeAfterInterruption_recovering 守卫)已采纳并扩到根上修——commit 597a89f

实测发现仅加 _recovering 守卫不足以消除"恢复中手势重复 resume":守卫只覆盖 suspend→100ms→resume 的前 100ms,而 iOS 上 interrupted → running 落地耗时约 300~400ms。100ms 后到 settle 之间 _recovering=false_needsUserGestureResume=true,手势穿过守卫 → AudioManager.resume() → 此时 _resumePromise 仍为 null(因 timer 是裸调 context.resume()、不占合并锁),于是又一次 context.resume()。iOS 真机恢复过程中狂点屏幕能稳定复现 2 次(node 也能复现)。

真根因:定时器走的是裸 context.resume(),绕过了 AudioManager.resume()_resumePromise 合并漏斗——这违反 _resumePromise 的设计本意(合并所有并发 resume)。

修法(最小、第一性):

  1. 定时器改走 AudioManager.resume(),占住 _resumePromise;窗口内手势调 AudioManager.resume()??= 短路、复用同一 promise,整窗口只一次 context.resume()
  2. 仍保留 _resumeAfterInterruption_recovering 守卫——它修的是"100ms 内手势抢跑导致 too-soon resume"(即你提到的 empirical delay 自洽),和上面修的"次数"是正交两件事(次数靠 _resumePromise 合并,质量靠守卫挡 too-soon)。

补了回归测试:恢复中(_recovering=true)的手势不触发 resume;恢复后 resume 未 settle 的 click 风暴被 _resumePromise 合并为单次 context.resume()

建议 1(resume() 加 hidden guard)暂不采纳——document.hidden 的拦截已在 play()(启动入口)和 _recoverPlaybackContext(恢复入口)做了;让 resume() 也判 hidden 会把同一职责散到第三处,且让一个名为 resume 的方法在 hidden 时去 suspend 在语义上略别扭。resume() 作为单一目的的解锁原语我倾向保留。但如果你认为有具体可达路径会让 AudioManager.resume() 在 hidden 时被误调,欢迎指出,我重新评估。

@GuoLei1990

Copy link
Copy Markdown
Member Author

小澄清(以免我上一条表述误导):严格说 B 单独已足以消除 "2 次 resume"(_resumePromise 合并 0~settle 全窗口的并发 resume,无论手势先到还是 timer 先到)。

A(_recovering 守卫)真正修的是 那 1 次 resume 的"时机":

  • 只 B:0~100ms 内首个手势 click 会立即触发 AudioManager.resume()context.resume()suspend 还没异步落地 时调用 → 触发注释里强调的 "resuming too soon after suspend is unreliable",iOS 上行为不可靠。
  • A + B:守卫挡掉 100ms 内手势 → 唯一 resume 落在 100ms 后由 timer 发起(suspend 早落地)→ 时序可靠。

所以分工:B 修"次数"、A 修"时机/too-soon"。两者正交、各管一件,合起来才让"100ms empirical delay"这条注释真正自洽。

(node trace 两个版本都验过、单测 37 全过、iOS 真机狂点验证从 2 次降为 1 次。)

合并/精简 _recoverPlaybackContext 与 _resumeAfterInterruption 周边的注释:把"100ms
经验值 + _recovering 为何挂 timer 而非 promise"两条合并到一处;把"走 AudioManager.
resume() 让 _resumePromise 合并"压成两行;删除单纯描述 WHAT 的 hidden/suspendedByCaller
分支注释(代码自身已直白)。核心 WHY 保留。仅注释。

@luzhuang luzhuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

总结

复审 HEAD d5d475a94(自上轮 8f96b2627 起 2 个 commit:597a89f9e 修复"恢复窗口内重复 resume"、d5d475a94 收紧注释)。这是我自己的 PR,故 --comment

597a89f9e 是对 recovery-window 竞态的正解,逐路径核对无问题:

  • _recoverPlaybackContext 的 100ms timer 从裸 context.resume() 改为走 AudioManager.resume(),借 _resumePromise ??=(:37)把"timer 自动恢复"与"用户手势恢复"合流去重——慢速 iOS interrupted→running 期间手势风暴不再触发多次 context.resume()
  • _resumeAfterInterruption(:133)新增 _recovering 早退守卫:100ms 窗口内手势不再绕过延迟去 resume 一个仍 interrupted 的 context;窗口结束后 _recovering=false,手势与 timer 经 _resumePromise 合一。
  • 无卡死路径:timer 先置 _recovering=false 再判 document.hidden || _suspendedByCaller 决定是否 resume,后续前台 _recoverPlaybackContext 可正常重入。

配套两条回归测试精准锁定新行为(ignores a gesture while recovery is in flight..._recovering 门、coalesces a click-storm..._resumePromise 去重),revert 生产改动即红。

问题

[P3] AudioManager.ts:113-114 — d5d475a94 收紧后的注释句子被截断,丢了"为什么"

// 100ms empirical delay (resume too soon after suspend is unreliable on iOS); _recovering is cleared
// on the timer rather than off a promise because iOS may never settle suspend/resume in interrupted

第二句结尾 ...in interrupted 是断的——interrupted 后面缺名词(state),且把原注释的后果从句"which would leave _recovering stuck true and block all later recovery"删掉了。原注释的价值正是这后半句(解释 不这么做会怎样),现在读者只看到"因为 iOS 可能 never settle"却不知道 never settle 会导致什么。建议补全为:...because iOS may never settle suspend/resume on an interrupted context, which would leave _recovering stuck true and block all later recovery.

简化建议

无。本轮是收口竞态 + 收紧注释,生产逻辑已干净。


注释合规:suspend() / resume() JSDoc 合规(多行块、@returns@remarks)。唯一问题是上面 P3 的截断注释(属"注释承诺/解释不完整")。其余 // 注释无句号、解释 为什么、附 WebKit bug 链接,合规。

CI 仅 labeler 跑(本 PR base 是 fix/audio-shaderlab-split 非 dev/2.0,全量 matrix 未触发),逻辑层我已逐路径核对。不阻塞合并。

无前缀 OfflineAudioContext 自 iOS 14.1 才有,老 iOS 上 AudioLoader.
_getDecodeContext() 会直接 ReferenceError、所有音频加载即崩。给 Polyfill 加
_registerOfflineAudioContext():在无 OfflineAudioContext 但有 webkitOfflineAudioContext
时做别名,并把其 callback 形式的 decodeAudioData 包成 Promise(对齐既有
_registerAudioContext 的处理)。

按 MDN browser-compat-data:webkitOfflineAudioContext 自 iOS 7。但 PR 链路还依赖
AudioContext.suspend()/resume() 返回 Promise(iOS 9+),故功能可用下限从 iOS 14.1
拓到 iOS 9。
@luzhuang luzhuang merged commit 2ba5494 into galacean:fix/audio-shaderlab-split Jun 23, 2026
1 check passed
@GuoLei1990

Copy link
Copy Markdown
Member Author

顺手拓宽兼容性范围(commit 6119703):

之前要求 iOS ≥ 14.5(其实精确应为 14.1+,已校正),老 iOS 上 new OfflineAudioContext() 会直接 ReferenceError → 音频加载即崩。给 Polyfill 加 _registerOfflineAudioContext():在无 OfflineAudioContext 但有 webkitOfflineAudioContext(自 iOS 7)时做别名 + 把其 callback 式 decodeAudioData 包成 Promise,对齐既有 _registerAudioContext 的写法(同一套模式实战检验过)。

数据来源 MDN browser-compat-data:

  • OfflineAudioContext 无前缀:Safari iOS 14.1+
  • webkitOfflineAudioContext 带前缀:Safari iOS 7+
  • AudioContext.suspend()/resume() 返回 Promise:Safari iOS 9+

所以功能可用下限从 iOS 14.1 拓到 iOS 9(suspend/resume Promise 形式是本 PR 链路硬依赖,iOS 9 是合成下限)。iOS 7-8 polyfill 也能让加载不崩,但因 promise 形式 API 缺失,整体链路仍不可用。

PR body 已更新(28 行 / 72 行)。

@GuoLei1990

Copy link
Copy Markdown
Member Author

采纳(commit 80cc39d)。抽成共享 helper _promisifyDecodeAudioData(proto: BaseAudioContext)_registerAudioContext_registerOfflineAudioContextwebkit* 别名赋值后各调一次,两份 wrapper 不会再 drift。

参数类型用 BaseAudioContext(而非 AudioContext | OfflineAudioContext)——decodeAudioData 本就是 BaseAudioContext 的成员、两者都继承它,语义更准也更简洁。纯重构、行为不变,净 -22 行,测试全绿。

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.

2 participants