TL;DR: 在“边收帧边播放”的场景里,预先缓冲 N 帧只是在猜网络速度。AudioContext.suspend() 会暂停音频输出,也会停止音频上下文的时间推进,因此可以把它当作同步门:下一帧没到就挂起时间轴,帧到了再恢复。
本文假设你了解 Web Audio API、
requestAnimationFrame和逐帧流式传输。
问题:帧和音频运行在两条轨道上
考虑一个流式口型同步播放器:后端通过 SSE 或 WebSocket 推送视频帧,客户端边接收、边解码、边绘制,同时播放与之对应的音频。
最直接的实现是立即启动音频,再根据 audioContext.currentTime 计算应该显示的帧:
audioSource.start(0);
const startTime = audioContext.currentTime;
function renderLoop() {
const elapsed = audioContext.currentTime - startTime;
const targetFrame = Math.floor(elapsed * fps);
if (frames[targetFrame]) {
drawFrame(frames[targetFrame]);
}
requestAnimationFrame(renderLoop);
}
网络稳定时没有问题。一旦帧到达速度落后于音频:
音频时钟:0s ───────── 2s ───────── 4s ─────────
到达帧: ████████████░░░░░░░░░░░░░░░░░░░░
显示画面:[正常] [画面冻结,音频继续播放]
用户会立刻感知到嘴型和声音分离。
为什么固定缓冲阈值不够
常见做法是在播放前先缓存一定数量的帧:
const BUFFER_THRESHOLD = 60;
onFrameDecoded(() => {
decodedCount += 1;
if (!started && decodedCount >= BUFFER_THRESHOLD) {
started = true;
audioSource.start(0);
renderLoop();
}
});
这个策略只把问题推迟了:
- 阈值依赖网络和后端速度,60 帧可能太少,也可能只是增加首帧延迟。
- 播放开始后的任何卡顿仍会让帧落后于音频。
- 为最坏情况配置缓冲,会让正常网络也承担额外等待。
根本问题是音频时钟独立运行,不知道下一帧是否可用。
关键性质:suspend 会停止时间推进
MDN 对 AudioContext.suspend() 的定义是暂停音频上下文中的时间推进,同时暂时停止访问音频硬件。它不只是静音:
const audioContext = new AudioContext();
// 播放一段时间后
console.log(audioContext.currentTime); // 例如 2.341
await audioContext.suspend();
await sleep(3000);
console.log(audioContext.currentTime); // 仍停留在挂起位置附近
await audioContext.resume();
// 时间轴从挂起位置继续推进
这使 AudioContext 可以充当同步原语:
下一顺序帧已经就绪?
是 → resume() → 时间推进 → 到达帧时间后绘制
否 → suspend() → 时间冻结 → 等帧到达事件唤醒
初始化:先挂起,再安排音频源
音频解码和帧接收可以并行。创建上下文后先挂起,解码完成后在挂起状态安排音频源:
const AudioContextConstructor =
window.AudioContext ??
(
window as typeof window & {
webkitAudioContext: typeof AudioContext;
}
).webkitAudioContext;
const audioContext = new AudioContextConstructor();
await audioContext.suspend();
const buffer = await audioContext.decodeAudioData(audioBytes);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.start(0);
const startTime = audioContext.currentTime;
start(0) 在挂起状态下可以安排播放,但不会让时间轴实际前进。第一帧和音频都准备好后,再由调度器调用 resume()。
浏览器的自动播放策略仍然适用。第一次允许发声的 resume() 通常需要发生在用户手势之后;生产代码不应吞掉它的 rejection,而应显示“点击播放”状态或把错误交给上层。
门控函数:用本地目标状态防抖
resume() 和 suspend() 都是异步操作。只读取 audioContext.state 容易遇到过渡窗口:调用已经发出,但 state 还没有更新。
可以用同步更新的本地标志表示期望状态:
let audioContext: AudioContext | null = null;
let audioRunning = false;
function ensureRunning() {
if (!audioRunning && audioContext) {
audioRunning = true;
audioContext.resume().catch((error) => {
audioRunning = false;
reportAudioError(error);
});
}
}
function ensureSuspended() {
if (audioRunning && audioContext) {
audioRunning = false;
audioContext.suspend().catch(reportAudioError);
}
}
本地标志用于避免在每个动画帧重复发送相同命令。它不能替代错误处理:resume() 被自动播放策略拒绝时,必须恢复标志并通知界面。
核心调度器:帧驱动时钟
调度器同时由两类事件唤醒:
- 下一顺序帧完成解码。
- 音频完成解码并准备好。
let audioReady = false;
let lastDrawnFrame = -1;
function tryAdvance() {
if (!isPlaying) return;
const nextNeeded = lastDrawnFrame + 1;
if (streamEnded && nextNeeded >= totalFrames) {
ensureSuspended();
isPlaying = false;
onPlayEnd();
return;
}
const nextFrame = frames[nextNeeded];
if (nextFrame === null) {
// 帧缺口:冻结时间轴,等待 onFrameArrived 再唤醒。
ensureSuspended();
return;
}
if (!audioReady || !audioContext) {
requestAnimationFrame(tryAdvance);
return;
}
ensureRunning();
const elapsed = audioContext.currentTime - startTime;
const frameTime = nextNeeded / fps;
if (elapsed >= frameTime) {
draw(nextFrame);
lastDrawnFrame = nextNeeded;
}
// 帧已到但时间未到时继续等待;绘制后也继续尝试下一帧。
requestAnimationFrame(tryAdvance);
}
这段逻辑同时处理两种速度:
- 后端慢于实时:遇到帧缺口就挂起音频。
- 后端快于实时:帧已经存在,但
elapsed < frameTime时仍等待音频时钟追上,不会提前播放。
新帧到达时只唤醒必要的工作
function onFrameArrived(index: number, bitmap: ImageBitmap) {
frames[index] = bitmap;
if (!isPlaying && index === 0) {
isPlaying = true;
tryAdvance();
return;
}
if (isPlaying && index === lastDrawnFrame + 1) {
tryAdvance();
}
}
如果帧按 0, 1, 3, 2, 4 的顺序到达,帧 3 只会先存入数组。帧 2 到达后才唤醒调度器,随后依次绘制 2、3、4。顺序由 lastDrawnFrame + 1 保证,无需额外排序。
结束与清理
停止播放时要同时取消调度、关闭音频上下文并释放 ImageBitmap 占用的 GPU 资源:
function stop() {
isPlaying = false;
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
audioContext?.close().catch(reportAudioError);
audioContext = null;
for (const bitmap of frames) {
bitmap?.close();
}
}
零帧流需要单独处理。因为常规流程由首帧启动,totalFrames === 0 时不会出现首帧事件;收到流结束信号后应直接调用结束回调,而不是等待 tryAdvance() 被唤醒。
音频时长、fps 和 totalFrames 也必须来自一致的元数据。如果音频先结束而视频仍有帧,currentTime 仍会前进,但用户已经听不到声音;门控无法替代输入数据的一致性校验。
取舍
| 优点 | 局限 |
|---|---|
| 网络抖动时仍保持严格音画同步 | 卡顿会表现为声音和画面一起暂停 |
| 首帧不必等待固定 N 帧缓冲 | 必须处理自动播放策略和异步状态 |
| 不需要 AudioWorklet | 频繁挂起可能带来可感知的播放停顿 |
| 乱序帧可自然按索引等待 | 不适合本地文件或已充分缓冲的媒体 |
如果帧来自本地文件或稳定的 HLS,直接让音频时钟驱动画面通常更简单。这套门控的价值在于帧到达时间不可预测的流式推送。