5.3 Web Audio 的限制与解锁
5.1 与 5.2 的混音图、音效池都是平台无关的纯 DSP。把它们接到浏览器上时,会遇到两条只在真实环境里才会咬人的限制:
- 自动播放策略:页面一加载,
AudioContext通常处于suspended,没有用户手势就不会出声。 - 时间精度与延迟:Web Audio 以 128 帧的「渲染量子」为处理粒度,
currentTime也是按量子推进;真实输出还有一段设备缓冲延迟。
这一节先把这两条讲清楚,并显式标注:涉及真实音频设备与自动播放解锁的部分,本节需在浏览器中手动验证,命令行不可复现。能算的部分——秒与帧的换算、量子量化、过去时间钳位、延迟补偿——单独抽成 L1 在 Node 里真跑,再用 OfflineAudioContext 在无头 Chromium 里把换算与 start(when) 的语义对上。
为什么值得这样切开?因为「限制」和「数学」是两类完全不同的东西。限制是平台契约:浏览器规定「没有手势就不出声」,这不是我们能改的,只能适配;而数学是我们自己的代码,可以脱离平台测试。把它们混在一起写,就会出现「因为测试环境没有音频设备,所以这段逻辑没法测」的假困境——实际上真正需要设备的是前者,后者一直都能测。本节接下来的每个小节都会明确标注它属于哪一类。
具体到本节,四件事各归其位:
- 自动播放解锁(L4):浏览器契约,只能靠真实手势验证。
- 秒帧换算与量子量化(L1):纯算术,Node 里可断言。
- 起始帧语义(L2):
OfflineAudioContext离线渲染可复现,不需要音频设备。 - 真实延迟与
currentTime精度(L4):依赖设备,只能上机量。
自动播放策略:为什么第一次没有声音(L4)
现代浏览器(含 Safari 与 Chrome)默认不信任「页面自己发起的声音」。新建的 AudioContext 初始 state 往往是 suspended,即使你 start() 了音源,也只是静默地排着队。唯一的官方出口是用户手势:在 pointerdown / keydown 这类事件的处理器里调用 ctx.resume(),上下文才会切到 running。
本节需在浏览器中手动验证,命令行不可复现:
AudioContext的自动播放状态机与用户手势事件在 Node 里都不存在,下面的代码只在真实页面里有意义。
src/subsys/audio/unlock.ts(L4)就是这段胶水:在首个手势里 resume(),一旦进入 running 就自行摘除监听,避免每次点击都白调一次。
// file: src/subsys/audio/unlock.ts
// tier: L4
//
// helio 子系统 5.3:自动播放解锁胶水。**本节需在浏览器中手动验证,命令行不可复现**
// ——它依赖真实 AudioContext 的自动播放策略与用户手势事件,Node 里没有这两个东西。
/** 该上下文此刻是否需要 resume?suspended 即为被自动播放策略挂起。 */
export function needsResume(ctx: AudioContext): boolean {
return ctx.state === 'suspended';
}
/**
* 在 target 上挂一个「用户手势即解锁」监听:首个 pointerdown 里调用 ctx.resume(),
* 一旦上下文进入 running 就自行摘除监听,避免每次点击都 resume。
* 返回一个手动摘除函数,便于热重载时清理。
*/
export function attachUnlockHandler(ctx: AudioContext, target: EventTarget): () => void {
const detach = (): void => target.removeEventListener('pointerdown', onGesture);
const onGesture = (): void => {
void ctx.resume().then(() => { if (ctx.state === 'running') detach(); });
};
target.addEventListener('pointerdown', onGesture);
return detach;
}
工程上还有两点经验(同样是浏览器里才能确认的):一是别等真的点了才开始加载——可以先用 suspended 的上下文把图搭好、把音源 start() 排上,手势一到 resume() 就无缝出声;二是把「是否已解锁」当成状态,running 之前触发的音效要排队或丢弃,否则会丢失开场的 UI 音。needsResume() 就是这个状态的判据。还有一点常被忽略:resume() 返回的是 Promise,在 iOS Safari 上它可能被拒绝,所以要用 .then() 确认 state === 'running' 再摘监听,而不是盲目假设一次调用就成功。
各浏览器的自动播放策略并不一致,这决定了引擎不能只写一条解锁路径:
- Chrome / Edge:允许在无手势时创建
AudioContext,但首个手势前保持suspended;手势后自动恢复。 - Safari(桌面与 iOS):最严格,
resume()必须在手势的同步调用栈里触发,异步延迟调用可能被拒。 - Firefox:默认较宽松,但会被系统的「自动播放阻止」与站点权限覆盖。
- 移动端整体:切后台常直接挂起上下文,回前台必须重新确认
state再决定是否resume()。
所以解锁代码的正确形态是「幂等 + 自检」:每次都确认 state === 'running' 才算成功,失败则保留监听等下一次手势。attachUnlockHandler 的 detach 只在确认成功后才摘监听,正是为了不在 Safari 上「假成功」后彻底失去机会。
AudioContext 生命周期与页面可见性(L4)
AudioContext 不是「建好就一直在响」。它的 state 在 running / suspended / closed 之间流转,而推动流转的往往不是你的代码,而是浏览器与操作系统:
- 后台标签页节流:页面不可见时,浏览器会降低主线程定时器频率;音频线程可能仍在跑,但你的调度回调被拖慢,表现为「后台切回来时音效晚了一拍」。
- 移动端后台:切到后台经常直接挂起上下文,回前台不会自动恢复。
- 音频焦点与省电:来电、其它 App 抢占音频焦点、系统低电量策略都可能让上下文被动
suspended。
本节需在浏览器中手动验证,命令行不可复现:上述状态流转由浏览器与操作系统驱动,Node 里既没有
AudioContext也没有可见性事件,只能在真实页面里观察。
引擎侧的应对是把「上下文当前能不能发声」当成每帧检查的状态,而不是一次性初始化:读 ctx.state,用 audioContextStateHint() 翻译成引擎动作,遇到 suspended 就重新挂手势解锁。这与 5.1 里「每帧读延迟、动态补偿」是同一种纪律——平台状态是输入,不是常量。
可运行产物:调度时间数学(L1)
解锁之外,音频调度里有一堆纯算术值得单独测:把秒换成帧、把帧向上量化到 128 帧的量子边界、把「已经过去的」目标时间钳到现在、把设备延迟换算成帧做补偿。下面是完整的 src/subsys/audio/schedule-time.ts:
// file: src/subsys/audio/schedule-time.ts
// tier: L1
// verify: npx vitest run src/subsys/audio/schedule-time.test.ts
//
// helio 子系统 5.3:Web Audio 的调度时间数学。真实设备/自动播放解锁无法在命令行
// 复现(见正文 L4 标注),但「秒 ↔ 帧」「量化到 128 帧渲染量子」「过去时间钳到
// 现在」「延迟补偿」都是纯算术,可以 L1 真跑。Node 26 直跑。
/** Web Audio 的渲染量子固定为 128 帧(AudioWorklet 与调度粒度的共同单位)。 */
export const RENDER_QUANTUM = 128;
/** 秒 → 帧(四舍五入到最近整数帧)。 */
export function secondsToFrames(seconds: number, sampleRate: number): number {
return Math.round(seconds * sampleRate);
}
/** 帧 → 秒。 */
export function framesToSeconds(frames: number, sampleRate: number): number {
return frames / sampleRate;
}
/** 向上量化到渲染量子边界:一次处理 128 帧,事件只会落在量子起点。 */
export function quantizeUp(frames: number, quantum = RENDER_QUANTUM): number {
return Math.ceil(frames / quantum) * quantum;
}
/**
* 把目标时间钳到「不早于现在 + minLeadFrames」。调度到过去会立刻发声并可能爆音,
* 故预留一个安全提前量。返回实际生效的帧位置。
*/
export function clampToNow(whenFrames: number, nowFrames: number, minLeadFrames = 0): number {
return Math.max(whenFrames, nowFrames + minLeadFrames);
}
/** AudioContext.state → 引擎该做什么。suspended 必须在用户手势里 resume。 */
export function audioContextStateHint(state: string): string {
if (state === 'running') return 'ready';
if (state === 'suspended') return 'needs-resume-on-gesture';
return 'closed';
}
/** 延迟补偿:把「输出到扬声器的延迟」换算成帧,调度时前移同样的量。 */
export function latencyFrames(
baseLatencySec: number, outputLatencySec: number, sampleRate: number,
): { base: number; output: number; total: number } {
const base = Math.round(baseLatencySec * sampleRate);
const output = Math.round(outputLatencySec * sampleRate);
return { base, output, total: base + output };
}
// ---- 直接运行:一个 48 kHz 上下文里的调度换算(Node 26 import.meta.main)----
if ((import.meta as { main?: boolean }).main) {
const rate = 48000;
console.log('0.001s ->', secondsToFrames(0.001, rate), 'frames');
console.log('quantizeUp(1)', quantizeUp(1), 'quantizeUp(128)', quantizeUp(128), 'quantizeUp(129)', quantizeUp(129));
console.log('clamp past ->', clampToNow(-500, 10000, 128));
console.log('state suspended ->', audioContextStateHint('suspended'));
const lat = latencyFrames(0.01, 0.02, rate);
console.log('latency base', lat.base, 'output', lat.output, 'total', lat.total);
}
本机实跑(node src/subsys/audio/schedule-time.ts):
0.001s -> 48 frames
quantizeUp(1) 128 quantizeUp(128) 128 quantizeUp(129) 256
clamp past -> 10128
state suspended -> needs-resume-on-gesture
latency base 480 output 960 total 1440
几个数字的含义:48 kHz 下 1 ms 是 48 帧;quantizeUp 把任意帧位置抬到 128 的整数倍(1→128,128→128,129→256),这正是「调度粒度只能落在量子边界」的建模;clampToNow(-500, 10000, 128) 把一个「已经过去 500 帧」的目标钳到「现在 + 128 帧」= 10128,避免迟到事件立刻爆音;latencyFrames 把 0.01s 的 base latency 与 0.02s 的 output latency 分别换成 480 与 960 帧。vitest 里另有 7 条断言逐条覆盖这些边界。
这套函数在引擎里的用法很具体:每当要排一个音效,就把「现在 + 延迟补偿」作为基准时刻,把请求时刻与它取 clampToNow,再 quantizeUp 到量子边界,最后除以采样率交给 start(when)。整条链路没有一次浮点比较是「靠感觉」的——每一个时间决策都落在这几个可测的纯函数上,这正是把它抽成 L1 的意义。
可运行产物:在真实 Web Audio 里验证调度时刻(L2)
L1 把换算算对了,但没证明「我们算出的帧位置 = Web Audio 真正开始发声的位置」。src/subsys/audio/schedule-l2.ts(L2)用 OfflineAudioContext 排一个「从第 128 帧起满幅」的音源——起始时刻由 framesToSeconds(128, 48000) 算出——离线渲染后回读采样,断言第 127 帧静音、第 128 帧满幅:
// file: src/subsys/audio/schedule-l2.ts
// tier: L2
// verify: node run-l2-local.mjs src/subsys/audio/schedule-l2.ts
//
// 5.3 的 L2 验收:把 L1 的「秒 ↔ 帧」换算放到真实 Web Audio 里验证。
// 用 OfflineAudioContext 排一个「从第 128 帧起满幅」的音源,回读采样,断言
// 起始帧之前静音、之后满幅——证明我们的换算与 start(when) 的语义一致。
// 用离线渲染,不需要真实音频设备,故可在无头环境复现。
import { framesToSeconds, secondsToFrames } from './schedule-time.ts';
export interface L2Check { readonly name: string; readonly ok: boolean; readonly detail: string }
export interface L2Result { readonly checks: L2Check[]; readonly data: Record<string, unknown> }
const RATE = 48000;
const FRAMES = 256;
const START_FRAME = 128;
export async function runL2(): Promise<L2Result> {
const checks: L2Check[] = [];
const push = (name: string, ok: boolean, detail: unknown): void => {
checks.push({ name, ok, detail: String(detail) });
};
const ctx = new OfflineAudioContext(1, FRAMES, RATE);
const buf = ctx.createBuffer(1, FRAMES, RATE);
buf.getChannelData(0).fill(1);
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
const when = framesToSeconds(START_FRAME, RATE); // 目标时刻(秒)
src.start(when);
const rendered = await ctx.startRendering();
const out = rendered.getChannelData(0);
push('起始帧之前静音', out[START_FRAME - 1] === 0, `out[${START_FRAME - 1}]=${out[START_FRAME - 1]}`);
push('起始帧起满幅', out[START_FRAME] === 1, `out[${START_FRAME}]=${out[START_FRAME]}`);
push('秒↔帧换算与 start(when) 语义一致',
START_FRAME === secondsToFrames(when, RATE), `frame=${secondsToFrames(when, RATE)}`);
push('渲染长度等于请求帧数', out.length === FRAMES, `len=${out.length}`);
return { checks, data: { rate: RATE, frames: FRAMES, startFrame: START_FRAME, when, before: out[START_FRAME - 1], at: out[START_FRAME] } };
}
本机实跑(headless Chromium,node run-l2-local.mjs src/subsys/audio/schedule-l2.ts):
PASS 起始帧之前静音 out[127]=0
PASS 起始帧起满幅 out[128]=1
PASS 秒↔帧换算与 start(when) 语义一致 frame=128
PASS 渲染长度等于请求帧数 len=256
out[127] = 0、out[128] = 1 把「起始帧」钉死在采样的整数位置上,说明我们算出的 when = 128 / 48000 秒确实对应 Web Audio 的第 128 帧。这条 L2 用离线渲染,不需要真实音频设备,因此可在无头环境里跑;它证明的是「调度换算的语义」,而不是「设备真的在那个时刻出声」——后者仍是 L4。
时间精度、延迟与真实设备(L4)
currentTime 是单调递增的,但它以渲染量子为步长推进,所以它不是高精度墙钟——想精确对齐到某帧,得靠上面的量子量化,而不是「睡到某个时刻」。延迟则来自三处叠加:上下文 baseLatency、设备 outputLatency、以及你自己 buffer 里的分块。三者都只能在真实设备上测得。
本节需在浏览器中手动验证,命令行不可复现:真实的
baseLatency/outputLatency数值、currentTime的推进步长、以及扬声器实际出声的时刻,都依赖具体设备与浏览器,Node 里既没有音频设备也没有AudioContext。本节的 L1/L2 只把「延迟换算成帧」与「起始帧语义」固定下来,真值必须上设备量。
还有一个容易踩的坑:如果主线程被长任务卡住,setTimeout 式的调度会抖动,但基于 currentTime 的绝对时刻调度不会——Web Audio 在音频线程上按量子推进,只要你在主线程提前把 start(when) 排好,卡顿不会让声音错位。这也是为什么引擎的音频调度应该「提前排、按绝对时刻」,而不是「到点了再触发」。
延迟的三处来源各管一段,值得分开看:
baseLatency:上下文自身的处理延迟,由浏览器缓冲策略决定,通常几毫秒。outputLatency:从音频线程到扬声器出声的那一段,设备相关,可能十几到几十毫秒。- 应用缓冲:你在 5.2 里自己排队的 PCM 分块大小;块越大越抗卡顿,但延迟越高。
latencyFrames 把前两者换算成帧,应用缓冲则是你自己要权衡的一个参数。这也解释了为什么「延迟」不是一个可以一次调好的常数:换台机器、换个浏览器,baseLatency 与 outputLatency 都会变。引擎的正确姿势是每帧读一次这两个值、动态补偿,而不是硬编码一个经验数字。
本节需在浏览器中手动验证,命令行不可复现:上表三个数值里,只有「应用缓冲换算成帧」这一段是纯算术,
baseLatency/outputLatency的真值必须由ctx.baseLatency/ctx.outputLatency在真实上下文里读出,Node 里没有AudioContext,也就无从取得。
另外,如果要做逐样本级的自定义处理(比如自带混响算法),GainNode 这类内置节点不够用,需要 AudioWorklet——它跑在音频线程上、以 128 帧为单位回调。但 AudioWorklet 的模块加载、线程模型与本节的主题(限制与解锁)是两件事,站内 /audio-worklet-realtime/
有专门讨论,这里不展开。
对《微光岛》这种轻量游戏,结论很务实:不要试图把音效对齐到帧级。用 5.2 的包络做淡入淡出掩盖调度抖动,用本节的状态提示处理解锁时序,把延迟当成一个固定的、可忽略的常数即可。真正需要采样级对齐的是音乐游戏或 DAW,那不是本卷的战场。
与引擎暂停 / 恢复的联动
游戏暂停时该怎么处理音频,常被做成「把所有 voice 停掉」,但那会在恢复时从头发声、丢掉 BGM 的进度。更干净的做法是暂停即 ctx.suspend()、恢复即 ctx.resume():整个图停在原地,currentTime 冻结,恢复后 BGM 从原处继续。
- 暂停:
await ctx.suspend(),保留所有节点与已排的start(when)。 - 恢复:先确认仍在用户手势的授权窗口内,再
ctx.resume();若已被系统挂起,走attachUnlockHandler的路径。 - 销毁:热重载或切场景时
ctx.close(),并调用解锁函数返回的detach,避免监听泄漏。
本节需在浏览器中手动验证,命令行不可复现:
suspend()/resume()/close()的实际行为、以及恢复后 BGM 是否真的从原处继续,都依赖浏览器实现与设备,Node 里无法复现。
这套联动的价值在于:它把「暂停」变成一次上下文状态切换,而不是一次图的重建。重建图会丢掉 5.2 里所有 voice 的包络相位与流式缓冲的读取游标,而 suspend() 一个字节都不会丢。
小结
这一节把 Web Audio 的「不可复现」与「可复现」切开:自动播放解锁、真实延迟、currentTime 精度是浏览器专属,正文显式标注为 L4、只能手动验证;而秒帧换算、量子量化、时间钳位、延迟补偿是纯算术,抽成 L1 用 vitest 逐条断言,再用 L2 的 OfflineAudioContext 把换算与 start(when) 的语义对上。这种「把平台的数学抽出来、把平台的行为标注掉」的做法,会是本卷后续每个子系统的通用套路。
本节交付的东西可以列成一张清单:
unlock.ts(L4):手势解锁胶水,浏览器手动验证。schedule-time.ts(L1):秒帧换算、量子量化、时间钳位、延迟补偿,7 条 vitest 断言。schedule-l2.ts(L2):用OfflineAudioContext验证起始帧语义,无头可复现。
音频到此告一段落。下一章转到与它并列的另一个输入源——设备抽象与输入事件队列:键盘、鼠标、手柄如何被归一化成引擎内部事件。有趣的是,输入也会遇到同构的问题:事件在浏览器里同样是异步、可能延迟、需要「手势」才激活,所以这一章的两分法(把数学抽出来、把平台行为标注掉)在下一章会再次出现。
阅读导航:上一节:5.2 音效池、BGM 与流式 · 下一节:6.1 设备抽象与事件队列 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。